운영체제 공룡책 4단원 정리

목차

운영체제 4단원, 스레드와 병행성 부분을 정리한다.

1. 스레드

하나의 프로세스가 오직 하나의 실행 흐름만 가지고 있다고 하자. 그러면 하나의 어플리케이션은 여러 프로세스를 만들어야 한다. 예를 들어서 웹브라우저에 관련된 프로세스는 하나는 웹페이지를 렌더링하고 하나는 네트워크에서 데이터를 검색한다고 생각할 수 있다.

하지만 이렇게 하나의 어플리케이션을 여러 프로세스로 나누는 것은 비효율적이다. 프로세스 생성 횟수도 많아지고, 컨텍스트 스위칭도 많이 발생하고 프로세스 간 통신도 많아진다. 그래서 운영체제는 프로세스 내에서 여러 실행 흐름을 만들 수 있도록 스레드라는 개념을 도입했다.

1.1 스레드의 개념

스레드는 CPU 이용의 기본 단위라고 할 수 있다. 일종의 실행 흐름이라고 생각하면 된다. 이는 스레드 id(tid), 각자의 PC, 레지스터 집합, 스택으로 구성된다. 그리고 스레드는 프로세스 내에 여러 개 있을 수 있는데 같은 프로세스의 다른 스레드와 코드, 데이터 영역 등의 운영체제 자원을 공유한다.

즉 하나의 프로세스가 여러 개의 실행 흐름(스레드)을 가질 수 있다는 것이다. 이런 다중 스레드 프로그래밍의 장점은 다음과 같다.

  • 여러 실행 흐름이 동시에 진행되므로 대화형 프로그램에서 사용자에게 더 즉각적인 응답을 제공할 수 있다.
  • 스레드는 프로세스 내 자원을 공유하므로 자원을 프로세스에 비해서 더 쉽게 공유할 수 있다.
  • 스레드를 만들고 스레드간 흐름을 스위칭하는 건 프로세스로 같은 작업을 하는 것보다 훨씬 비용이 적다.
  • 다중 처리기 시스템에서는 각 스레드가 다른 처리기에서 병렬로 수행될 수 있다.

1.2 멀티코어 프로그래밍

요즘 나오는 컴퓨터 시스템은 하나의 컴퓨팅 칩에 여러 코어를 설치한다. 그리고 운영체제는 각 코어를 하나의 CPU로 인식하게 된다. 이런 멀티코어 시스템에서는 하나의 프로세스를 여러 개의 스레드로 나누어서 각각의 스레드를 다른 코어에서 동시에 실행시킬 수 있다. 이는 일부 스레드가 정말로 동시에, 병렬적으로 실행될 수 있음을 의미한다.

여기서 동시성(concurrency)과 병렬성(parallelism)의 차이가 나온다. 병렬성은 코어(혹은 cpu)여러 개를 사용하여 실제로 스레드 여러 개를 동시에 실행하는 것이다. 아래 사진을 보면 스레드 여러 개가 정말로 같이 진행되는 것을 볼 수 있다.

parallel

하지만 코어가 하나뿐이라도 여러 개의 작업을 동시에 진행할 수는 있다. 여러 개의 스레드 사이를 빠르게 전환해 가며 하나의 프로세서에서 실행시키면 사용자는 여러 개의 작업이 동시에 진행되는 것처럼 느낄 수 있다. 이렇게 여러 작업을 동시에 진행하는 것을 동시성(concurrency)이라고 한다.

다음 사진은 동시성에 관한 사진인데 CPU가 하나밖에 없어서 스레드 3개가 번갈아가면서 실행되는 것을 볼 수 있다. 따라서 병렬성 없이도 동시성이 가능하다.

concurrency

CPU가 하나뿐이라도 동시성을 실현해서 여러 스레드를 프로세서에 올리는 게 뭐가 좋을까? 아무런 이득도 없을 것 같지만 계산이 빨라진다. 여러 스레드 중 입출력 작업이 많은 스레드 블럭을 진행할 때(I/O bound block) 다른 스레드는 계산을 진행할 수 있기 때문이다. 이렇게 하면 하나의 프로세서라도 더 효율적으로 작업할 수 있다.

하지만 이런 병렬 프로그래밍은 쉽지 않다. 먼저 프로그램을 분석해서 독립된 병렬 가능한 작업으로 나눌 수 있어야 한다. 그리고 그 작업들을 코어마다 균등하게 나눠서 진행할 수 있어야 하고 각 작업이 접근하는 데이터가 독립적으로 나눠져 있어야 한다. 그리고 마지막으로 작업들이 서로 의존하지 않아야 한다. 이렇게 작업들이 독립적이고 서로 의존하지 않는다면 병렬 프로그래밍이 가능하다. 병렬 프로그래밍은 디버깅도 일반적인 단일 스레드 프로그래밍보다 어렵다.

2. 다중 스레드 모델

2.1 스레드의 유형

스레드는 사용자 스레드와 커널 스레드로 분류될 수 있다.

2.1.1 사용자 스레드

사용자 스레드는 사용자 공간에서 제공되고 커널의 지원 없이 관리된다. 그리고 스레드 라이브러리를 통해 관리된다. 모든 것이 커널에 의존하지 않고 사용자 영역에서 이루어지므로 운영체제에서 스레드를 지원할 필요가 없고 OS 스케줄러에서 컨텍스트 스위칭이 발생하지도 않는다. 같은 이유로 커널을 호출하지 않기 때문에 인터럽트 발생 시 커널 스레드보다 오버헤드가 적다.

2.1.2 커널 스레드

커널 스레드는 커널 레벨에서 동작하고 커널에 의해 직접 관리된다. 따라서 커널에 종속적이다. 이는 장점도 있는데 커널이 각 스레드를 개별적으로 관리해 줄 수 있고 커널이 직접 관리하는 스레드라 안정성과 다양한 기능이 제공된다.

단 사용자 모드-커널 모드 전환이 빈번해 성능 저하가 발생하고 구현이 어렵다. 또한 자원도 더 많이 소비하는 편이다. 스케줄링, 동기화를 위해 커널 스레드에서 커널을 호출할 때 이 연산이 무겁고 오래 걸리는 단점도 있다.

단 이 커널 스레드의 수는 병렬성의 성능을 결정하는데 멀티스레드 환경에서 결국 연산을 처리하는 것은 커널 스레드이기 때문이다.

2.2 스레드 매핑 모델

스레드에는 사용자 스레드와 커널 스레드가 있지만 멀티스레드 환경의 경우 둘 사이에는 모종의 연관이 존재해야 한다. 멀티스레드 환경이 아니라면 유저 레벨 스레드가 그냥 프로세서에서 바로 실행되면 되므로 굳이 매핑이 필요 없다.

하지만 현재 대부분의 운영체제 환경은 멀티스레드이므로 이 유저 스레드-커널 스레드 매핑은 중요하다. 다대일 모델, 일대일 모델, 다대다 모델이 존재한다.

2.2.1 다대일 모델

다대일 모델은 유저 스레드가 여러 개 있을 때 이를 하나의 커널 스레드로 매핑하는 방식이다. 스레드 관리는 사용자 공간의 스레드 라이브러리가 한다.

문제는 하나의 스레드가 block 시스템 콜을 하면 유일한 커널 스레드가 봉쇄되므로 다른 스레드도 모두 block 되어 버린다는 것이다(blocking problem). 그리고 멀티프로세서나 멀티코어 환경에서 효율적으로 사용하기 어렵다.

한 번에 하나의 스레드만 커널에 접근할 수 있기 때문에 실제 연산의 흐름이 하나뿐이기 때문이다.(사용자 스레드에서 아무리 많은 작업을 실행하더라도 결국 실제 연산은 커널 레벨에서 이루어지니까)

2.2.2 일대일 모델

일대일 모델은 하나의 유저 스레드를 하나의 커널 스레드로 매핑한다. 하나의 스레드가 blocking 시스템 콜을 호출해도 다른 커널 스레드가 연산을 처리할 수 있기 때문에 더 많은 병렬성을 제공한다.

사용자 스레드를 만들기 위해서는 하나의 커널 스레드를 만들어야 하기 때문에 커널 스레드가 많아지면 많아질수록 오버헤드가 커진다는 것이 단점이다. 윈도우, 리눅스에서 이 모델을 쓴다.

2.2.3 다대다 모델

다대다 모델은 여러 유저 스레드를 그보다 작거나 같은 수의 커널 스레드로 매핑한다. 이때 커널 스레드 수는 기기나 응용 프로그램에 따라 다르다.

다대다 모델에서는 사용자가 원하는 만큼 유저 스레드를 생성할 수 있다. 그리고 거기에 대응되는 커널 스레드가 병렬로 수행될 수 있고 blocking 시스템 콜을 호출해도 다른 커널 스레드가 연산을 처리할 수 있다.

즉 다대다 모델에서 사용자는 원하는 만큼 유저 스레드를 생성한다. 운영체제는 그 유저 스레드들에 대응하는 커널 스레드를 여러 개 만들어서 알아서 유저스레드-커널스레드를 대응시킨다.

2.2.4 2수준 모델

다대다 모델의 변형이다. 이는 여전히 사용자 스레드 N개에 대해 N보다 작거나 같은 커널 스레드를 대응시킨다. 하지만 특정 사용자 스레드를 위해서는 커널 스레드와의 1-1 매핑을 제공한다. 이는 빨리 처리해야 하거나 점유율이 높은 사용자 스레드를 빠르게 처리하는 데에 유리하다.

다대다 모델이 제일 좋아 보이지만 실제로는 구현이 어렵다. 또한 대부분의 시스템에서 처리 코어 수가 많기에 커널 스레드 수를 제한하는 것의 중요성이 줄었다. 따라서 윈도, 리눅스 등 대부분의 운영체제에서 일대일 모델을 채택하고 있다.

2.3 스레드와 코어

그럼 유저스레드와 커널 스레드는 어떻게 상호작용하는가? 일단 다대다 모델을 채택하는 시스템에서는 대부분 유저 스레드와 커널 스레드 간에 중간 자료구조를 둔다. 이것이 가상 프로세서(virtual processor)이다. 경량 프로세스(LightWeight Processor, LWP)라고도 한다.

아무튼 사용자 스레드 라이브러리는 실행될 유저스레드를 선택하고 LWP를 구성함으로써 사용자 레벨 스케줄링을 한다. LWP는 또 각각 하나의 커널 스레드에 매핑되어 있다.

즉 사용자가 사용자 스레드 라이브러리를 이용해 스레드들에 작업을 할당하면 라이브러리는 알아서 스케줄링을 하고 LWP를 구성한다. 그리고 커널에서는 LWP들을 이용해 물리적인 코어들에 작업을 스케줄링하는 것이다.

따라서 진짜 병렬성이라고 할 수 있는 건 사실 사용자 스레드가 여러 개 있는 것이 아니고 물리적인 코어가 여러 개 있는 것이다. 스레드와 코어가 1:1 대응이 될 때 true parallelism을 실현할 수 있다.

3. 스레드 풀

요청이 있을 때마다 스레드를 매번 만들어 주고 요청이 끝나면 없애 주는 것은 물론 프로세스에 대해 그런 일을 하는 것보다는 효율적이지만 여전히 비용이 드는 작업이다. 이를 해결하는 방법 중 하나가 스레드 풀이다. 프로세스를 시작할 때 일정한 수의 스레드들을 pool로 만들어 주고 요청이 올 때마다 풀에서 스레드를 할당해 주는 방식이다.

만약 풀에 사용 가능한 스레드가 없다면 사용 가능한 스레드가 생길 때까지 작업이 대기된다. 또한 요청을 완료한 스레드는 다시 사용 가능한 상태로 풀에 돌아간다.

이렇게 하면 새 스레드를 만들어서 요청에 대응하는 것보다 기존 스레드를 사용하는 게 더 빠르기 때문에 더 빠르게 스레드를 활용할 수 있다. 또한 존재할 수 있는 스레드 개수에 제한을 둘 수 있다. 스레드 개수는 풀의 크기를 넘어갈 수 없기 때문이다.

4. 스레드와 관련된 문제들

4.1 fork, exec 시스템 콜

멀티스레드 환경에서는 fork에 문제가 좀 있다. 한 프로그램의 스레드가 fork를 호출했다면 새 프로세스는 fork를 호출한 그 스레드만 복제해야 하는가, 아니면 모든 스레드를 다 복제해야 하는가?

이는 exec에 달렸다. 만약 fork 이후 exec가 실행된다면 어차피 자식 프로세스는 exec의 매개변수로 전달된 프로그램으로 대체될 것이므로 모든 스레드를 복제할 필요가 없다(즉 fork를 호출한 스레드만 복제하면 된다).

그러나 fork 후에 exec를 하지 않는다면 모든 스레드를 다 복사해 줘야 한다. 몇몇 유닉스 기종은 이러한 2가지 fork를(모든 스레드 전부 복사하는 fork, fork를 호출한 스레드만 복사하는 fork)모두 지원한다.

4.2 신호

신호는 유닉스 시스템에서 어떤 특정한 이벤트가 발생했다고 알려주는 데 쓰인다. 신호에는 동기식(synchronous)신호와 비동기식(asynchronous) 신호가 있다. 둘의 차이는 다음과 같다.

  • 동기식 신호 : 신호를 발생시킨 연산을 수행한 프로세스와 같은 프로세스로 전달되는 신호. 허용되지 않은 메모리 접근이나 0으로 나누는 등의 신호이다.

  • 비동기식 신호 : 실행 중인 프로세스 외부로부터 발생한 신호. Ctrl+C 와 같이 특정 키 입력에 의해 프로세스가 종료되거나 타이머 만료, kill 명령어를 통한 프로세스 종료 등이 있다. A 프로세스를 종료시키는 신호는 A 프로세스 외부에서 오는 것이 자연스럽다.

그런데 이 신호를 어떻게 처리할지에서도 문제가 발생한다. 각 프로세스가 단일 스레드만 가진다면 간단하다. 신호가 발생하면 프로세스에 그냥 전달하면 된다. 어차피 그 신호를 받을 스레드는 하나뿐일 것이기 때문이다.

문제는 하나의 프로세스가 여러 스레드를 가질 때 발생한다. 어떤 프로세스가 신호를 받았다면 그 신호를 프로세스 내의 어떤 스레드에 전달할 것인가? 다음과 같은 선택지들이 있다.

  • 신호가 적용될 스레드에 전달

  • 프로세스 내 모든 스레드에 전달

  • 몇몇 스레드를 선택해 전달

  • 특정 스레드를 지정해 모든 신호를 전달받도록 설정

4.3 스레드 취소

스레드가 끝나기 전에 강제 종료하는 경우가 있을 수 있다. 이렇게 취소되어야 할 스레드를 target thread라고 한다. 이런 스레드 취소엔 2가지 방식이 있다.

  • 비동기식 취소(asynchronous cancellation) : 타깃 스레드를 즉시 종료. pthreads에선 이렇게 스레드를 즉시 종료하는 함수로 pthread_cancel(tid)를 제공한다. 문제는 target thread에 할당된 자원을 온전히 회수하지 못할 수도 있다는 것이다.

  • 지연 취소(deferred cancellation) : 목적 스레드가 주기적으로 자신이 종료되어야 할지를 점검하도록 한다. 그리고 만약 종료되어야 한다면 종료한다. 예를 들어 pthreads api에선 pthread_testcancel 이라는 함수를 제공한다. 이 함수는 스레드에 대해 취소 요청이 보류중인 걸로 확인되면 스레드를 취소시킨다.

4.4 Thread Local Storage(TLS)

TLS는 각 스레드가 가지는, 자신만이 접근할 수 있는 데이터 영역이다. 함수 하나에 종속된 지역 변수가 아니라 전체 함수 호출에 걸쳐서 보인다는 점에서 static과 비슷하다.

예를 들어서 A라는 함수가 있고 TLS의 특정 변수를 1씩 증가시키는 역할을 한다고 하자. 그러면 A라는 함수가 실행될 때마다 TLS의 그 변수는 0부터 시작한다고 하면 1,2,3...으로 늘어나는 식으로 작동하는 것이다. 단 다른 스레드에서의 그 변수는 여전히 0일 것이다.

참고

사용자 스레드와 커널 스레드에 관하여 https://www.crocus.co.kr/1255