스레드 — 한 프로세스 안의 여러 실행 흐름
1. 흐름이 하나뿐일 때
앞 글 끝에서 프로세스 하나는 제어 흐름 하나였다고 했습니다. 명령어가 한 줄씩 이어지는 흐름이 하나뿐이라는 뜻입니다. 그런데 한 프로그램 안에서도 여러 일을 동시에 하고 싶을 때가 많습니다. 웹 브라우저는 페이지를 그리면서 동시에 파일을 내려받고, 사용자의 클릭에도 바로 반응해야 합니다. 흐름이 하나뿐이면 내려받는 동안 화면이 멈춥니다. 이 글은 한 프로세스 안에 흐름을 여러 개 두는 방법, 스레드를 봅니다.
2. 스레드는 프로세스 안의 흐름 하나입니다
스레드는 한 프로세스 안에서 도는 실행 흐름 하나입니다. 프로세서가 실제로 붙잡고 실행하는 단위가 이 스레드입니다. 프로세스보다 작은 단위이고, 한 프로세스 안의 스레드들은 그 프로세스의 자원을 함께 씁니다. 그러면서도 스레드마다 제 것으로 갖는 것이 있습니다. 자기 번호, 다음에 실행할 명령어를 가리키는 프로그램 카운터, 레지스터 값들, 그리고 스택입니다. 흐름마다 지금 어디까지 왔는지가 다르고, 함수를 부르며 쌓은 값도 다르기 때문입니다.
주소 공간에서 보면
주소 공간 네 구역으로 봅니다. 스레드가 하나일 때는 코드, 데이터, 힙, 스택이 하나씩 있습니다. 스레드가 둘이 되면 코드와 데이터와 힙은 그대로 하나를 함께 씁니다. 같은 전역 변수를 두 스레드가 똑같이 봅니다. 달라지는 것은 스택입니다. 스레드마다 스택이 하나씩 따로 생겨, 빈 공간 안에 스택이 두 개 자리 잡는 모양이 됩니다. 그래서 함수 안에서 만든 지역 변수는 스레드마다 따로이고, 함수 밖의 전역 변수는 모두가 함께 씁니다.
스레드에도 기록장이 있습니다
운영체제는 프로세스마다 PCB라는 기록장을 갖고 있습니다. 스레드에도 저마다의 기록장이 있습니다. 스레드 제어 블록이라고 하고 줄여서 TCB라고 부릅니다. 여기에는 스레드 번호, 지금 상태, 스택 위치, 프로그램 카운터, 레지스터 값들이 적힙니다. 그리고 이 스레드가 속한 프로세스의 PCB를 가리키는 연결이 있습니다. 메모리나 열린 파일 같은 공통 정보는 PCB에 한 번만 적고, 스레드마다 달라지는 것만 TCB에 적습니다.
3. 왜 프로세스를 여러 개 띄우지 않나
여러 일을 동시에 하려면 fork로 프로세스를 여러 개 띄워도 됩니다. 그런데 프로세스를 새로 얻는 일은 비쌉니다. 주소 공간을 통째로 복사하고, PCB를 새로 채우고, 파일 목록까지 챙겨야 합니다. 요청을 초당 수천 개씩 받는 웹 서버에서 요청마다 fork를 부르면 준비하는 데만 시간이 다 갑니다. 스레드라면 주소 공간은 이미 있는 것을 함께 쓰니 스택과 TCB만 새로 마련하면 됩니다. 게다가 서로 데이터를 주고받을 때도 공유 메모리나 파이프 없이, 그냥 같은 변수를 보면 됩니다.
정리하면 스레드의 장점은 넷입니다.
- 반응성 — 한 스레드가 입출력을 기다리며 멈춰도 다른 스레드는 계속 돌아서 화면이 굳지 않습니다.
- 자원 공유 — 같은 주소 공간을 쓰니 따로 통신 수단을 마련할 필요가 없습니다.
- 경제성 — 만드는 비용도 싸고, 스레드끼리 갈아타는 비용도 프로세스 사이의 문맥 교환보다 적습니다. 주소 공간을 바꿀 필요가 없기 때문입니다.
- 확장성 — 코어가 여러 개인 컴퓨터에서 스레드들을 각 코어에 나눠 실제로 동시에 돌릴 수 있습니다.
4. 동시성과 병렬성
동시성은 여러 일이 모두 조금씩 진행되고 있는 상태입니다. 코어가 하나여도 됩니다. 잘게 번갈아 돌리면 여러 일이 함께 나아갑니다. 병렬성은 여러 일이 정말로 같은 순간에 실행되는 것입니다. 이것은 코어가 여러 개여야 가능합니다. 코어 하나에서 스레드 넷을 돌리면 동시성은 있지만 병렬성은 없습니다. 코어 넷에 스레드 넷을 하나씩 올리면 둘 다 있습니다.
병렬로 나누는 방식도 둘입니다. 같은 일을 데이터만 나눠 코어마다 맡기는 데이터 병렬성, 그리고 서로 다른 일을 코어마다 맡기는 작업 병렬성입니다.
5. 코어를 늘리면 얼마나 빨라지나
코어를 두 배로 늘리면 두 배 빨라질까요. 대개 그렇지 않습니다. 프로그램 안에는 나눌 수 있는 부분과, 반드시 한 흐름으로 차례대로 해야 하는 부분이 섞여 있기 때문입니다. 차례대로 해야 하는 부분의 비율을 S, 코어 수를 N이라고 하면 빨라지는 배수의 최댓값은 다음과 같습니다. 이것을 암달의 법칙이라고 합니다.
빨라지는 배수 ≤ 1 / (S + (1 − S) / N)
프로그램의 75%는 나눌 수 있고 25%는 차례대로 해야 한다고 합니다. S는 0.25입니다. 코어를 2개로 늘리면 0.25 더하기 0.75를 2로 나눈 0.375, 합쳐서 0.625입니다. 1을 0.625로 나누면 1.6입니다. 두 배가 아니라 1.6배입니다.
문제 1
같은 프로그램에서 코어를 4개로 늘리면 최대 몇 배 빨라질까요. 코어를 한없이 늘리면 최대 몇 배까지 빨라질 수 있을까요.
코어가 4개면 나눌 수 있는 0.75를 4로 나눈 0.1875에 차례대로 해야 하는 0.25를 더해 0.4375입니다. 1을 0.4375로 나누면 약 2.3입니다. 코어를 네 배로 늘렸는데 2.3배 빨라집니다. 코어를 한없이 늘리면 0.75를 N으로 나눈 값은 0에 가까워지고 0.25만 남습니다. 1을 0.25로 나누면 4입니다. 코어가 아무리 많아도 4배를 넘지 못합니다. 차례대로 해야 하는 25%가 전체 속도의 천장을 정하는 것입니다. 그래서 빠르게 만들고 싶다면 코어를 늘리기 전에 나눌 수 없는 부분부터 줄여야 합니다.
6. 사용자 스레드와 커널 스레드
스레드는 누가 관리하느냐에 따라 둘로 나뉩니다. 사용자 스레드는 커널 바깥, 곧 사용자 모드에서 도는 라이브러리가 만들고 관리합니다. 만들 때 시스템 콜이 아니라 보통의 함수 호출을 쓰고, 커널은 이 스레드가 있는지조차 모릅니다. 커널을 거치지 않으니 스레드끼리 갈아타는 것이 빠릅니다. 커널 스레드는 커널이 직접 만들고, 관리하고, 프로세서에 배정합니다. 프로세스보다는 싸지만 사용자 스레드보다는 비쌉니다. 커널 자신도 여러 커널 스레드로 일합니다. 장치를 맡는 스레드, 메모리를 맡는 스레드, 인터럽트를 처리하는 스레드가 따로 돕니다.
7. 사용자 스레드를 커널 스레드에 잇는 방식
사용자 스레드가 실제로 프로세서를 받으려면 커널 스레드에 이어져야 합니다. 잇는 방식은 셋입니다.
- 다대일 — 사용자 스레드 여럿을 커널 스레드 하나에 잇습니다. 한 스레드가 입출력으로 멈추면 그 커널 스레드가 멈추니 나머지도 전부 멈춥니다. 커널 스레드가 하나라 코어가 여럿이어도 병렬로 돌 수 없습니다. 그래서 지금은 거의 쓰지 않습니다.
- 일대일 — 사용자 스레드 하나마다 커널 스레드를 하나씩 붙입니다. 하나가 멈춰도 나머지는 돌고 병렬로도 돕니다. 대신 스레드가 늘면 커널 스레드도 그만큼 늘어 부담이 됩니다. 리눅스와 윈도가 이 방식입니다.
- 다대다 — 사용자 스레드 여럿을 그보다 적거나 같은 수의 커널 스레드에 나눠 잇습니다. 병렬로도 돌고 커널 스레드 수도 조절할 수 있지만, 구현이 복잡해 흔하지 않습니다.
어느 방식이든 프로그래머가 쓰는 쪽에서 스레드를 만드는 방법은 같습니다.
8. 스레드를 만드는 법
리눅스 같은 유닉스 계열에서 스레드를 만드는 표준 방법은 pthread라는 라이브러리입니다. pthread_create를 부르면서 새 스레드가 실행할 함수와 그 함수에 넘길 값을 줍니다. 그러면 그 함수를 실행하는 흐름이 하나 새로 생기고, 원래 흐름도 다음 줄로 계속 갑니다. fork와 달리 프로세스를 복사하지 않습니다. 같은 주소 공간 안에 흐름만 하나 늘어납니다. 새 스레드가 끝나기를 기다리려면 pthread_join을 부릅니다. 프로세스의 wait에 해당하는 일입니다.
순서는 정해져 있지 않습니다
메인 흐름이 첫 줄을 출력하고, A를 출력하는 스레드와 B를 출력하는 스레드를 차례로 만든 뒤, 둘을 모두 기다렸다가 마지막 줄을 출력한다고 합니다. 실행하면 A가 먼저 나올 때도 있고 B가 먼저 나올 때도 있습니다. 만든 순서가 A, B여도 실제로 누가 먼저 프로세서를 받는지는 운영체제가 정하기 때문입니다. 확실한 것은 첫 줄이 맨 앞, 마지막 줄이 맨 뒤라는 것뿐입니다. 순서는 이렇게 흔들리지만, 두 스레드가 같은 메모리를 본다는 것은 흔들리지 않습니다.
9. 프로세스와 스레드, 10과 11
문제 2
전역 변수 값이 10인 프로그램이 두 가지 방식으로 일을 나눕니다. 첫 번째는 fork로 자식 프로세스를 만들고, 자식이 전역 변수에 1을 더한 뒤 끝나면 부모가 wait로 기다렸다가 전역 변수를 출력합니다. 두 번째는 스레드를 하나 만들고, 그 스레드가 전역 변수에 1을 더한 뒤 끝나면 메인이 pthread_join으로 기다렸다가 전역 변수를 출력합니다. 각각 몇이 출력될까요.
fork는 주소 공간을 통째로 복사합니다. 자식이 1을 더한 것은 자식의 복사본입니다. 부모의 전역 변수는 그대로 10이라 부모는 10을 출력합니다. 스레드는 주소 공간을 복사하지 않고 함께 씁니다. 전역 변수는 데이터 구역에 하나만 있고 두 흐름이 같은 칸을 봅니다. 새 스레드가 1을 더하면 그 칸이 11이 되고, 메인은 그 스레드가 끝나기를 기다렸다가 같은 칸을 읽으니 11을 출력합니다. 10과 11, 이것이 프로세스와 스레드의 차이입니다.
10. 스레드를 다룰 때 조심할 것
함께 쓰는 것이 편한 만큼 조심할 것도 생깁니다.
- 스레드가 자기 스택에 만든 지역 변수의 주소를 결과로 돌려주면 안 됩니다. 스레드가 끝나면 그 스택이 사라지므로, 받은 쪽은 이미 사라진 메모리를 가리키게 됩니다.
- 다른 스레드를 도중에 멈추게 하는 것을 취소라고 하는데, 방식이 두 가지입니다. 곧바로 끝내는 비동기 취소는 공유 데이터를 고치던 도중에 끊을 위험이 있습니다. 그래서 기본값은 지연 취소입니다. 스레드가 스스로 확인하는 지점에 왔을 때만 끝납니다.
- 전역 변수는 모든 스레드가 함께 쓰지만, 스레드마다 따로 갖는 전역 변수가 필요할 때도 있습니다. 이것을 스레드 지역 저장소라고 합니다.
11. 스레드를 알아서 만들어 주는 방식
스레드를 직접 하나하나 만들고 기다리는 대신, 라이브러리나 컴파일러에게 맡기는 방식도 있습니다. 대표가 스레드 풀입니다. 스레드를 미리 몇 개 만들어 두고, 일이 들어오면 쉬고 있는 스레드에 맡깁니다. 새로 만드는 시간을 아끼고, 스레드 수가 미리 만들어 둔 개수를 넘지 않게 묶어 둘 수 있습니다. 앞에서 본 웹 서버가 흔히 이렇게 합니다.
또 하나는 OpenMP입니다. 코드에서 병렬로 돌려도 되는 구역에 표시만 해 두면, 컴파일러가 코어 수만큼 스레드를 만들어 나눠 돌립니다. 반복문 하나를 표시하면 반복을 코어별로 나눠 맡기는 식입니다.
정리
스레드는 한 프로세스 안의 실행 흐름 하나입니다. 코드와 데이터와 힙은 함께 쓰고, 프로그램 카운터와 레지스터와 스택은 따로 갖습니다. 프로세스보다 만들기도 갈아타기도 싸고, 코어가 여럿이면 실제로 병렬로 돕니다. 다만 얼마나 빨라지는지는 나눌 수 없는 부분이 정합니다. 사용자 스레드는 커널 스레드에 이어져야 돌고, 리눅스는 하나에 하나씩 잇습니다. 이제 프로세서 위에는 여러 프로세스와 여러 스레드가 차례를 기다리고 있습니다. 누구에게 먼저, 얼마 동안 프로세서를 줄 것인가. 다음 글에서는 그 결정, CPU 스케줄링을 봅니다.