본문 바로가기
학부공부/OS_운영체제

27. Thread(2)

by sonpang 2021. 12. 10.
반응형

오늘은 thread에 대한 이야기를 이어나갈까 합니다. 지난 포스팅에서 스레드가 왜 필요한지 스레드의 구성 요소, 유저 스레드와 커널 스레드 등에 대해 살펴보았었습니다. 지난 포스팅을 마치며 스레드에 대한 개념을 배운다면 이전에 배웠던 스케쥴링과 IPC에서 프로세스를 기준으로 설명한 issue들을 다시 점검해 볼 필요가 있다고 말씀드렸습니다.

2021.12.08 - [학부공부/OS_운영체제] - 26. Thread(1)

 

26. Thread(1)

오늘은 thread에 대해 이야기 해볼까 합니다. Process에 대해 복습하고 넘어갈 필요가 있는데요. 프로세스 이야기를 하면서 더 이상 프로그램이라고 표현하지 않겠다고 이야기드렸습니다.(하나의

ku320121.tistory.com

 

 

27.01. Thread & OS

시스템 콜을 배우면서 프로세스 기반으로 설명하였습니다. 프로세스 관련 시스템 콜 semantics는 당연히 프로세스 기준으로 작성되었을 겁니다. 하지만 스레드가 생겼으니 스레드 개념에 대한 고려가 필요하게 되었습니다. 예를 들면 fork()와 exec()같은 시스템 콜이 스레드를 지원하기 위해서는 변화가 필요합니다. 또한 스레드의 종료 문제도 있습니다. 이전에 exit()을 설명드린 적 있습니다. exit()은 프로세스가 terminate하는 것인데요. Multithread가 동작하는데 그 중 하나의 스레드에서 exit()하면 커널은 어떻게 할 것인지 고민해야 합니다. 당연히 프로세스보다 종료가 복잡합니다.(Thread cancellation issue)

 

추가적으로 Multithreaded programming을 어떻게 지원할 것인지에 대해서도 살펴보아야 합니다. 스레드 스케쥴링, 스레드간 통신 방법, 스레드가 사용하는 stack과 스레드 specific data등 메모리 공간 할당을 어떻게 할 것인지에 대한 고민이 필요합니다.

 

 

27.02. Threading Issues

27.02.1. Threading Issues_Creation

Multithreaded program에서 fork()와 exec() 시스템 콜의 semantics가 달라져야 한다고 말씀드렸습니다. fork()는 하나의 프로그램 내의 스레드가 fork를 호출하면 모든 스레드를 가지고 있는 프로세스를 만들 것인지 아니면 fork를 요청한 스레드만을 복사한 프로세스를 만들 것인지 결정해야 합니다. Linux에서는 2가지 version의 fork를 모두 제공하고 각각의 경우를 처리해줍니다.(fork 자체가 스레드라는 개념이 존재하지 않던 시절 만들어졌다는 것을 기억해보시면 됩니다.)

 

프로세스와 스레드 data structure를 정리한 내용을 보고 가겠습니다.

 

전통적인 프로세스 data structure =  Process Context + (data, code, stack)

현대 프로세스 data structure = Thread + (code, data) + Kernel context

 

Process Context = Program Context + Kernel Context, Thread = Thread context + User stack

 

스레드는 control flow 즉 PC(Program Counter)를 가진다는 것을 기억하실 필요가 있습니다. 또 프로세스는 논리적 구조가 tree의 형태라면 스레드는 동일한 level이라 복제가 tree처럼 이루어지지 않는다는 것을 살펴볼 필요가 있습니다.

 

스레드가 존재하지 않던 시절에는 exec()을 하지 않고 부모 프로세스와 동일한 메모리를 같게 해 병렬 처리에 사용하기도 하였는데요. fork()가 multithread 환경에서는 fork()를 호출한 스레드를 제외한 나머지 스레드가 제대로 동작하지 않을 수도 있습니다. Memory leak issue도 있을 수 있지만 fork()가 복사한 메모리에는 heap, stack외에도 중요한 무언가가 있습니다. 사실 이 내용은 다음 포스팅에서 살펴볼 동기화와 관련이 있는데요. 잠깐 설명해드리자면 mutex, condition variable이 다른 스레드에서 사용된 채로 fork()된다면 해당 variable로 protect되는 critical section에는 다시 진입할 수 없게 됩니다. malloc()같은 function은 대부분 global mutex를 사용하기 때문에 별도로 code에 mutex가 없어도 안심할 수 없게되는 것이죠. 이를 해결하려면 fork()하기 전 이런 환경을 check하는 것이 필요합니다. 아니면 fork()후 바로 exec()을 하는 것도 방법이 될 수 있습니다.

 

fork()를 하여 모든 스레드를 복사할 경우 exec()을 수행하면 모든 스레드들은 새로운 프로그램으로 교체됩니다. 이렇게 교체될 스레드들을 복사하는 것은 불필요한 작업입니다. 따라서 fork()를 하고 exec()을 수행하는 경우에는 fork()를 요청한 스레드만 복사하는 것이 좋습니다. 반대로 fork()를 하고 exec()을 수행하지 않는다면 모든 스레드의 복사가 필요할 수도 있습니다. 이런 부분들은 앞서 말씀드렸듯이 기존 시스템 콜들이 스레드라는 것을 생각하지 않고 만들어졌고 지원하지 않았기 때문에 tricky하게 생각할 필요가 있습니다.

 

27.02.2. Threading Issues_Cancellation

스레드 cancellation은 스레드의 작업이 끝나기 전에 외부에서 작업을 중지시키는 것입니다. 하나의 스레드에서 중지 명령이 결국은 다른 스레드의 모든 작업을 중지시켜야 하는 경우 등에서 발생합니다.(예를 들면 드라이브 다운로드 과정에서 사용자가 일시중지를 선택하여 취소할 경우 읽어오는 스레드는 중지되어야 합니다.) 여기서 생각해 볼 문제는 스레드에게 할당된 자원을 어떻게 할 것인가에 대한 문제입니다. 시스템의 자원을 사용하고 있는 스레드가 중지될 경우 할당된 자원을 함부로 해제할 수 없는데 그 자원을 다른 스레드가 사용할 수도 있기 때문입니다. 

 

스레드 취소는 즉시 강제종료하는 비동기식 취소(Asynchronous cancelation)과 주기적으로 자신이 종료되어야 할지를 확인하는 지연 취소(Deferred cancellation)가 있습니다. 앞서 말씀드렸듯이 할당된 자원 문제가 스레드 취소를 어렵게 하는데 비동기식 취소는 자원을 회수하지 못하게 될 가능성이 있고 지연 취소는 스레드들이 자신이 취소되어도 안전하다고 판단되는 시점(Pthread는 이 지점을 cancellation point라고 합니다.)에서 취소 여부를 검사할 수 있으므로 비교적 안전합니다.

 

27.02.3. Threading Issues_Thread pools

Thread pools는 스레드가 자주 생성되고 제거되는 상황에서 스레드를 생성하는 시간이 실제 스레드가 동작하는 시간과 비교해 무시할 수 없는 정도로 비중이 커지는 것을 막기 위해 고안된 것입니다. Request가 들어올 때마다 스레드를 생성하고 destroy하지 말고 pool을 생성해 미리 메모리에 띄어 놓자는 개념입니다. 요청이 들어오면 pool에서 하나 꺼내어 binding 시켜 execution시키고 끝나면 pool로 다시 넣는 방식인거죠. 예를 들면 웹서버에서 요청이 들어올 때 웹서버 자체가 처리할 때도 있지만 요청에 대해 스레드를 만들고 스레드에게 처리하는 것을 던져주는 것입니다. 다만 스레드는 시스템의 자원이기 때문에 시스템의 동작을 보장하는 최대한의 스레드 수에 대한 제한이 필요합니다. 요청 별 처리하는 스레드의 수를 제한하여 전체적인 부하를 control할 수 있습니다.(스레드 pool을 만들어 프로세스가 새로 실행될 때 정해진 수 만큼의 스레드를 만든 후 pool에 할당하는 겁니다. 새로운 스레드가 필요하면 pool에서 가져오고 작업이 끝나면 스레드를 다시 pool에 넣어둡니다.) 이런 방식을 통해 제한된 수의 스레드를 관리하여 스레드 생성에 걸리는 cost를 줄일 수 있고 너무 많은 스레드 생성에 따른 시스템의 부하도 막을 수 있습니다. 그럼 당연히 하나의 질문을 하게 되는데요. Pool에 몇 개를 두어야 할까요?

사실 굉장히 알기 어렵습니다. 가사오하 할 때 prevision은 대게의 경우 하드웨어 maximum으로 할당합니다. 언젠가 있을지 모르는 pick를 처리하기 위해서죠. 하지만 이런 방식은 cost 측면에서 비효율 적입니다. 그래서 가상화가 prvisioning을 쉽게 해준다는 것이 그런 pick가 물리는 곳으로 하드웨어를 쉽게 이동시킬 수 있게 해준다는 것입니다. 정리하자면 스레드의 수는 시스템의 메모리나 예상되는 작업의 수에 따라 heuristic하게 결정됩니다.

 

27.02.4. Threading Issues_IPC

Multithread programming에서는 스레드 간 통신이 필요합니다. 왜 필요할까요?

사실 스레드가 만들어진 이유 중 하나이기도 합니다. 스레드 간 통신이 필요할 경우 별도의 자원을 이용하지 않고 전역 변수 공간을 이용하여 data를 주고받을 수 있습니다. 프로세스 간 통신에 비해 훨씬 저렴한거죠.

 

스레드 간 IPC를 구현할 때 까장 효율적인 방법은 공유 메모리입니다. 스레드가 메모리를 공유하기 때문입니다. 또 공유 메모리가 자연스럽게 가능합니다. 하지만 공유 메모리가 모든 commmunication 문제를 해결하진 못하고 socket을 사용해야 하는 경우도 있습니다. 다른 프로세스의 스레드와의 통신을 할 때는 프로세스 간 통신과 비슷한 성능을 보이게 될겁니다. 이런 통신이 빈번하다면 프로그램 설계를 수정해야 할 필요가 있습니다. 정리하자면 같은 프로세스 내의 스레드 간에는 소켓없이 공유 메모리를 사용하는 것이 효율적입니다. 물론 multithread programming을 해야하는 복잡성은 존재하지만요.

 

 

27.03. Implementation

User level에서는 thread library를 지원합니다.(라이브러리에서 유저 스레드를 만들면 커널 스레드와 매핑은 라이브러리가 못합니다. 단, 스케쥴링은 가능합니다.) 여러 OS에서 스레드를 운영하기 위해 POSIX가 존재하는데요. POSIX Pthread는 interoperability를 위한 표준입니다.(IEEE 1003.1c: pthread_create()) Windows threads API에는 CreateThread() 시스템 콜이 있고 리눅스는 조금 달리 처리하는데 clone()이라는 시스템 콜을 지원합니다. 이는 뒤에서 조금 더 자세히 설명드리겠습니다. Language level에서는 java의 thread class가 있습니다.(저는 잘 모르지만 java advance까지 하신 분들은 아실 수도 있습니다.)

 

User library가 스레드를 스케쥴링하면 커널 스케쥴러는요?

커널 스케쥴러가 할당한 time slice안에서 스레드를 유저 라이브러리가 스케쥴링합니다. 즉, 2개의 level에서 스케쥴링이 가능한 것이고 이는 스레드가 프로세스라는 context 안에 있기 때문입니다.

 

 

27.04. Linux

스레드 프로그래밍은 매우 어렵습니다. 스레드를 지원하는 것이 어렵다는 거죠. 그래서 리눅스는 스레드를 프로세스로 구현하였습니다. 이렇게 한 이유는 semantics 차이도 주어야 하고 새로운 시스템도 도입해야 합니다. 하지만 backward compatible 문제가 있습니다. 또 앞서 말씀드렸다시피 스레드 프로그래밍은 매우 어렵습니다. 스레드 간 동기화, 디버깅이 힘듭니다.(Timing dependency. 디버거 내에서는 잘 동작하지만 실제로 동작시키면 잘 동작하지 못하는 경우가 빈번합니다.) 

 

프로세스를 만들되 스레드처럼 resource를 공유합니다.(Windows와 다른 UNIX-descendants과 차이점 중 하나입니다.)  이러면 새로운 시스템 콜을 사용하지 않아도 됩니다. 그러면서 core가 많을 때 스레드가 많아지면 프로세스가 많아지는 것이므로 하나의 프로세스가 core위에서 각각 동작하면 되는 것입니다. 이때 resource 공유는 커널이 제공합니다. 앞으로 virtual memory를 배우면 어떻게 프로세스를 통해 스레드를 구현할 수 있는지 더 깊이 이해할 수 있을 겁니다. 지금도 UNIX 계열은 스레드를 사용합니다. Linux에서만큼은 오히려 프로세스를 사용하여 스레드를 처리하는 방향으로 가고 있는데요. 그럼에도 커널 스레드라는 것은 존재합니다. 이는 커널이 multithread를 지원하여 커널에서 인터럽트가 발생할 수 있게 해주기 위함입니다.(이전에 시스템 콜이 들어오면 시스템 콜 처리가 종료될 때까지 다른 프로세스가 진입하지 못한다고 설명하였습니다. 인터럽트도 마찬가지였죠. 하지만 CPU 속도가 빨라지면서 인터럽트조차 기다릴 시간이 없어졌습니다. 결국 커널 내부 동작 자체가 schedulable하고 preemptable해질 필요가 있었습니다.)

 

이전 포스팅에서 유저 스레드와 커널 스레드 mapping에 대해 알려드렸는데요. 리눅스에서는 mapping이 당연히 필요없습니다. 

 

사실 어떠한 approach가 더 좋느냐는 어떤 시스템인지에 따라 다릅니다. 리눅스 approach가 좋다고 주장을 해보자면 커널이 복잡해져도 프로그램을 쉽게 할 수 있지만 리눅스의 approach가 아니라면 사용자들이 동기화를 이해해야 합니다. 이러면 복잡하고 bug가 생길 가능성이 높아지겠죠. 스레드 level로 프로그래밍하는 것은 여간 어려운 일이 아닙니다. 커널을 프로세스화해서 사용하는 것이 프로그래밍 관점에서 보았을 때 많이 편리해진 것입니다. 물론 커널이 복잡하고 모든 case를 처리해야하는 단점은 있습니다. 하지만 앞서 말씀드렸듯 프로그래밍 관점에서 편리해지는 것은 좋은 일입니다. 일반적인 프로그래밍은 쉽고 직관적이어야 하죠. 물론 그렇지 않은 OS도 있을 수 있습니다. 덧붙여 말씀드리자면 스레드 mapping뿐 아니라 OS 처리 choice에 따라 꽤 성능차이가 많이나는 시대입니다. (어쩌면 리눅스가 server side에 많이 사용되는 것이 multicore로 가면서 성능이 좋아서 그런 것일 수도 있습니다.) 

 

Implementation에서 잠시 언급한 clone()에 대해서도 말씀드리고 넘어가겠습니다. Clone API는 스레드 API를 사용하지 않고 몇 개의 프로세스를 group으로 묶어 스레드처럼 동작하게끔 하는 것으로 생각하시면 됩니다.

 

 

오늘은 스레드에 대해 알아보는 마지막 시간이였습니다. Multicore를 지원한다는 것은 프로그램이 multithreading이 되어 있다는 의미입니다. 물론 할당하는 것은 OS가 합니다.(Multithreading이 되어있지 않다면 사실 multicore자체가 의미가 없습니다.) 그런 점에서 스레드는 굉장히 중요한 topic입니다. 또 최근 발전하고 있는 기술들과 가장 전면에서 마주칠 base라는 생각이 듭니다. 질문과 오류지적은 언제나 환영이니 댓글로 남겨주시면 감사하겠습니다. 요즘 유튜브에 좋댓구알이라는 말이 유행이더라고요.(좋아요 댓글 구독 알림의 줄임말입니다.) 저도 한번 해보겠습니다. 여러분 좋댓구알 해주세요~~

반응형

댓글