logo
logo

Sunghun Son / Operating System / 스레드

Created Wed, 05 Jul 2023 17:11:46 +0900
4767 Words

개요

스레드는 CPU 이용의 기본 단위이다. 자체적으로 스레드 ID, 프로그램 카운터, 레지스터 집합과 스택을 가진다. 같은 프로세스에 속한 다른 스레드들과 운영체제 자원을 공유한다.

동기

프로그램이 복잡해지고 동시에 처리해야 하는 일이 많아졌다. 네트워크에서 데이터를 가져오면서 동시에 키보드 입력을 받아들여야 하고, 그래픽을 표시해야 한다. 서버는 수 만 개의 트래픽을 하나씩 처리할 수는 없기 때문에 동시해 작업해야 한다.

각각의 작업마다 프로세스로 만들 수도 있을 것이다. 하지만 프로세스를 만드는 것은 시간도 오래걸리고 불필요한 자원을 가지게 된다. 프로세스는 무겁다. 이것의 해결책으로 자원은 프로세스의 자원을 같이 쓰는 가벼운 스레드를 사용한다. 서버는 요청 하나당 하나의 스레드를 만들면 가벼우면서 병렬로 작업을 처리할 수 있게 된다. 현대 운영체제들은 커널에서도 스레드를 사용해 병렬 작업을 수행한다.

장점

  • 응답성 : 멀티태스킹이 CPU를 쉬지 않게 하는 것이라면, 멀티스레딩은 프로세스를 쉬지 않게 하는 것이다. 스레가 시간이 오래 걸리는 일을 하고 있어도 급한 일은 다른 스레드를 만들어 처리할 수 있다.
  • 자원 공유 : 프로세스만 고유의 자원을 갖는다. 스레드는 부모 프로세스의 자원을 공유하기 때문에 같은 주소 공간에 여러 스레드가 접근할 수 있다.
  • 경제성 : 스레드는 프로세스보다 가볍다(lightweight). 오버해드가 적기 때문에 성능이 증가한다.
  • 확장성 : 스레드는 하나의 CPU에 할당되기 때문에, 멀티프로세서 환경에서는 스레드 여러 개가 동시에 처리될 수 있다.

멀티코어 프로그래밍

컴퓨터는 단일 CPU 시스템에서 시작해 다중 CPU 시스템으로 발전했고, 최근에는 하나의 CPU 안에 여러 코어를 넣은 멀티코어 시스템으로 발전했다. 운영체제에게 각 코어는 하나의 프로세서로 보인다. 멀티코어 시스템에는 각 코어마다 스레드를 할당할 수 있기 때문에 병렬적으로 실행할 수 있다. 더 나아가 현대에는 하나의 코어에 여러 스레드를 지원하는 방향으로 발전하고 있다.

병렬(parallel) 실행은 여러 테스크를 동시에 수행하는 것을 말한다. 반면, 병행(concurrent) 실행은 시분할 시스템처럼 여러 테스크를 번갈아가며 실행해서 동시에 수행되는 것처럼 보이는 것을 말한다.

프로그래밍 도전과제

멀티코어는 하드웨어와 소프트웨어 모두 지원해야 사용이 가능하다. 소프트웨어 개발자가 멀티코어를 활용하기 위해 고려할 점은 다음과 같다.

  • 테스크 식별 : 어플리케이션을 독립되고 병행 가능한 테스크로 쪼개야 한다. 각 테스크는 독립적이기 때문에 각 코어에서 동작할 수 있다.
  • 균형 : 각 테스크가 전체 작업에 균등한 기여도를 가지게 해야 한다. 불필요한 테스크를 많이 실행하는 것은 낭비다.
  • 데이터 분리 : 어플레케이션을 테스크로 나누는 것처럼 데이터도 각 코어에서 사용할 수 있도록 나눠야 한다.
  • 데이터 종속성 : 한 테스크를 수행하기 위해서 다른 테스크가 만든 데이터가 필요할 경우, 종속성을 적절히 동기화해줘야 한다.
  • 테스트 및 디버깅 : 병렬 실행은 실행 과정을 예측하기 힘들기 때문에 테스트와 디버깅이 어렵다.

병렬 실행 유형

  • 데이터 병렬 : 1~100까지 더할 때, 데이터를 n등분으로 나눠 n개의 코어에게 데이터를 할당한다. GPU가 이 방식을 주로 쓴다.
  • 테스크 병렬 : 테스크(스레드)를 코어에 할당한다. CPU에서 주로 쓴다.

다중 스레드 모델

스레드는 사용자 스레드와 커널 스레드로 나뉜다. 사용자 스레드는 어플리케이션이 관리하고, 커널 스레드는 운영체제가 관리한다. 사용자 스레드가 어떻게 시스템 호출을 하는지에 따라 3가지 모델로 나눌 수 있다.

다대일 모델 (Many-to-One)

여러 사용자 스레드가 하나의 커널 스레드를 사용한다. 사용자 공간의 스레드 라이브러리가 스레드를 관리하기 때문에 효율적이다. 하지만 한 번에 한 스레드만 커널 스레드를 사용할 수 있기 때문에 병렬 실행이 불가능하다. 또 한 스레드가 blocking 방식으로 시스템 호출을 사용하면 다른 스레드는 커널 스레드를 완전히 사용할 수 없다. 과거에 Solaris와 GNU에서 채택했다.

일대일 모델

스레드별로 하나씩 커널 스레드를 가지고 있기 때문에 다대일보다는 병렬성이 높다. 단점은 커널 스레드를 계속 생성해야 하기 때문에 오버헤드가 발생한다. Linux와 옛날 Windows가 채택했다.

다대다 모델

n개의 사용자 스레드와 n개 이하의 커널 스레드를 연결한 모델로, 멀티플레스라고 한다. 다대다 모델과 일대일 모델을 둘 다 허용하는 두 수준 모델(two-level model)도 있다.

사용자와 커널 스레드 매핑

사용자와 커널 스레드 매핑

스레드 라이브러리

프로그래머에게 스레드를 생성하고 관리하는 API를 제공한다. 라이브러리를 구현하는 방법에는 두 가지가 있다. 먼저 커널의 지원 없이 완전히 사용자 공간에서 구현하는 방법이다. 다른 방법은 커널 수준 라이브러리를 만드는 방법인데, 이 경우에는 시스템 호출을 통해 API를 사용해야 한다.

스레딩 전략에는 비동기 스레딩과 동기 스레딩이 있다., 비동기 스레딩은 부모 스레드가 자식 스레드를 만든 뒤 각자 실행되는 방식이다. 동기 스레딩은 포크-조인(fork-join)이라고 불리며, 자식 스레드가 모두 종료될 때까지 기다린 후 자식이 반환하는 정보를 가지고 다음 명령을 시행한다. 아래는 스레드 라이브러리를 설명한다.

  • Pthreads : POSIX 표준 API 명세이다. 각 운영체제는 독자적으로 명세를 구현하며 커널 수준 라이브러리다.
  • Windows 스레드 라이브러리 : Pthreads와 거의 유사한 커널 수준 라이브리다.
  • Java 스레드 API : JVM에서 사용 가능한 사용자 수준 API이다. JVM은 호스트 운영체제의 스레드 라이브러리를 사용해 구현하다.

암묵적 스레딩

스레드 관리 책임을 컴파일러와 런타임 라이브러리에게 넘겨 사용자가 직접 관리하지 않게 할 수 있다. 이를 암묵적 스레딩이라고 한다. 암묵적 스레딩을 통해 멀티 코어에서 멀티 스레드 프로그램을 설계하는 방법을 알아보자.

스레드 풀

앞서 서버는 요청이 들어올 때마다 스레드를 만들어 처리하면 병렬적으로 요청을 처리할 수 있다고 했다. 이 방법은 무척 효율적이지만 스레드를 생성/삭제하는데 오버헤드가 발생한다. 그리고 스레드도 약간의 자원을 사용하기 때문에 과도하게 생성하면 자원이 고갈된다.

스레드풀은 일정 개수를 미리 만들어 놓고 돌려가면서 쓰는 방식이다. 스레드는 평소에는 한가하다가 요청이 들어오면 하나씩 요청을 처리한다. 요청을 다 처리하면 다시 한가한 상태로 돌아간다. 풀에 남아있는 스레드가 없으면 가용 스레드가 생길 때까지 기다린다. 이 방식은 요청을 더 빠르게 처리하면서, 한 번에 사용할 수 있는 스레드 수에 제한을 둬 병행 처리가 불가능한 시스템에 도움을 준다. 또한 스레드를 미리 만들기 때문에 특정 시간에 작업을 하거나 주기적으로 작업을 하는 등의 전략을 취할 수도 있다.

스레드 풀을 동적으로 관리해 더 효율적으로 관리할 수도 있는데 Grand Central Dispatch가 그 예이다.

OpenMP

OpemMP는 C, C++, Fortran으로 작성된 API와 컴파일러 지시문이다. #pragma omp parallel 지시문에 묶인 코드를 병렬 영역이라 한다. 그러면 OpenMP는 시스템의 코어 개수만큼 스레드를 만들고 병렬 영역을 실행한다. 실행이 끝나면 스레드는 종료된다.

Grand Central Dispatch

Grand Central Dispatch(GCD)는 Apple이 만든 C 언어의 API와 런타임 라이브러리를 확장한 기술이다. 요청 개수와 시스템의 성능에 따라 스레드풀을 동적으로 조정한다.

OpenMP와 비슷하게 GCD는 ^{ 코드 } 안의 코드를 블록이라 한다. 블록은 디스패치 큐에 넣어져 스케줄되고, 스레드 풀에 있는 가용 스레드를 선택해 할당한다. 디스패치 큐는 직렬(serial)과 병행(concurrent) 두 유형을 모두 사용한다.

  • 직렬 큐(메인 큐) : 프로세스별로 하나씩 있으며, FIFO 방식을 사용한다. 먼저 제거된 블록은 다른 블록이 제거되기 전까지 실행을 끝내야 한다.
  • 병렬 큐 : 시스템은 우선순위별로 세 개의 병렬 큐를 가지고 FIFO 방식을 사용한다. 블록은 동시에 제거될 수 있다.

스레드와 관련된 문제들

fork와 exec 시스템 호출

프로세스 생성에서 배운 대로 fork는 프로세스를 복제하고, exec은 새 프로그램을 덮어씌운다고 했다.

만약 스레드가 fork를 호출하면 프로세스는 모든 스레드를 복제해야 할까 아니면 한 fork를 호출한 스레드만 복제해야 할까? 몇몇 UNIX 기종은 두 가지 버전의 fork를 모두 제공한다. 다만 fork를 하고 곧바로 exec을 할 경우 fork는 의미가 없어지기 때문에 낭비다.

신호 처리

프로그래밍에서 예외 처리를 동기식 신호로 하는 것으로 추측한다! (요약자 주)

단일 스레드 시스템

신호는 UNIX에서 프로세스에게 어떤 사건이 일어났음을 알려주는 수단이다. 불법적으로 메모리에 접근하거나 0으로 나누는 등의 행동을 하면 동기식 신호가 발생한다. 동기적 신호는 신호를 발생시킨 프로세스에게 전달된다. 반면, ctrl+c 같이 외부에서 프로세스에 주는 신호는 비동기식으로 발생한다.

신호는 두 가지 처리기에서 처리될 수 있다. 먼저 사용자 정의 처리기를 호출해 신호를 처리하게 하다. 만약 없으면, 커널은 디폴트 신호 처리기를 사용한다. 모든 신호는 무조건 반드시 처리되어야 하며, 무시하는 것도 처리하는 것이다.

다중 스레드 시스템

단일 스레드 시스템에서는 신호를 프로세스로 보내면 되지만, 다중 스레드 시스템에서는 어떤 스레드로 신호를 보내야 할 지 결정해야 한다. 경우에 따라 하나의 스레드, 특정 스레드들 혹은 모든 스레드에 신호를 보낼 수 있다.

  • 불법 메모리 접근 같은 동기식 신호는 같은 스레드 내에서 처리되어야 한다.
  • ctrl+c 같이 프로세스를 종료하는 신호는 프로세스 내 모든 스레드에 전달되어야 한다.

UNIX에서는 kill(pid, signal) 함수나 pthread_kill(tid, signal) 함수를 호출해 신호를 처리한다. Windows는 신호를 지원하지 않지만 비동기식 프로시저 호출로 흉내내서 사용한다.

취소

스레드가 끝나기 전에 강제 종료하는 작업을 스레드 취소라 한다. 스레드가 불필요해지거나 사용자가 요청했을 경우에 스레드를 취소할 수 있어야 한다. 이처럼 취소할 스레드를 목표 스레드라 부르고 두 가지 방식으로 취소한다.

  • 비동기식 취소(Asyncronous cancellation) : 한 스레드가 목표 스레드를 강제 종료한다. 운영체제가 자원을 회수하지 못 할 수 있다.
  • 지연 취소(Deferred cancellation) : 목표 스레드가 주기적으로 강제 종료가 가능한지 확인한다. 뒤처리를 끝내고 질서정연하게 종료한다.

Pthreads에서 pthread_cancel(target_tid) 함수를 사용해 취소한다. 지연 취소가 디폴트이고, 스레드가 취소점에 도달했을 때 취소한다. 취소점은 pthread_testcancel() 함수를 호출해 만든다. 스레드가 취소점 함수에 도달하면 정리 처리기 함수가 호출되고, 정리 처리기는 스레드의 모든 자원을 반환하게 한다.

스레드-로컬 저장소

한 프로세스 안의 스레드는 프로세스의 데이터를 모두 공유한다. 이 방식은 유용하지만 혼자만 접근해야 하는 데이터가 있을 수 있다. 그런 데이터는 스레드-로컬 저장소(TLS)에 넣는다.

스케줄러 액티베이션

스케줄러 액티베이션은 사용자 스레드와 커널 스레드 간의 통신 방법 중 하나다.

스케줄러 엑티베이션

스케줄러 엑티베이션

다대다 또는 두 수준 모델을 구현하는 시스템은 주로 중간에 경량 프로세스(LWP)라 불리는 자료구조를 둔다. 사용자 스레드 라이브러리는 스케줄 가능한 가상 처리기로 본다. 그리고 라이브러리는 사용자 스레드에게 적절히 가상 처리기를 할당한다. 커널은 스레드 라이브러리에게 LWP 집합을 제공하고, 스레드 라이브러리는 이 집합을 스케줄링한다. 커널이 특정 사건을 알려야 할 때는 upcall을 보내고, upcall 처리기에서 이를 처리한다.

예를 들어 사용자 스레드가 blocking 연산을 수행하면 커널은 이 사실을 upcall을 통해 upcall 처리기에 알린다. 그런 다음 커널은 새로운 LWP를 스레드 라이브러리에 할당한다. 그러면 upcall 처리기는 block된 스레드의 상태를 저장하고 이 스레드가 실행 중이던 가상 처리기를 회수한다. 회수한 가상 처리기는 다른 스레드에 스케줄링한다.

block된 스레드가 기다리던 사건이 발생하면 커널은 block이 끝났다고 upcall을 날린다. 그런 다음, 커널은 새로운 가상 처리기를 할당하거나 사용자 스레드 하나를 upcall 처리기에 할당한다. upcall 처리기는 저장했던 스레드 상태를 불러오고 다시 스케줄링한다.

Linux 스레드

리눅스는 프로세스와 스레드를 구분하지 않고 태스크라는 용어로 통일한다. clone 시스템 호출이 사용되면 자식 태스크와 얼마나 자료구조를 공유할 지 결정해 플래그를 전달한다. 거의 모든 자원을 공유하도록 플래그를 전달하면 스레드를 만드는 것이 된다.

  • fork : 부모 프로세스의 관련된 자료구조를 복사해 태스크를 만든다.
  • clone : 플래그에 따라 부모 태스크의 자료구조를 가리키는 태스크를 만든다. (포인터를 사용하는 듯 하다)