컴퓨터에서 무엇인가를 동시에 실행시킨다는 개념은 초창기부터 있었다. 실제로 1970년대 유닉스 구현에는 이미 멀티태스킹이라는 개념이 포함되어 있었다. 하지만 이런 방식이 범용적으로 사용되기 까지는 오랜 시간이 걸렸다. 1995년 Windows 95가 나오기까지 PC 운영체제를 거의 독점했던 DOS 시절에는 한번에 하나의 프로그램이 실행되는 것이 상식이었고, 다른 프로그램과 동시에 실행되는 프로그램을 만드는 것이 대단한 테크닉으로 취급 받았다. Windows 3.1의 등장과 함께 멀티태스킹이란 개념이 일반인들에게 본격적으로 소개되었다. Windows 3.1은 DOS 위에서 구동되는 프로그램이었지만 여러모로 현재 Windows의 기틀을 다진 운영체제였다. 비선점형 멀티태스킹 방식을 지원했고 현재 사용되는 GUI의 기본 개념을 소개한 운영체제였다. 이런 시기를 거쳐 Windows 95가 등장하면서 DOS 시절에 굳어졌던 일반인들의 상식은 완전히 뒤집혔다. 이제 프로그램은 원래 당연히 동시에 실행되는 것이고, 운영체제에서 멀티태스킹이란 당연히 지원해야 되는 기능이 돼버렸다. Windows 95의 선점형 멀티태스킹은 일반인들에게 그런 인식을 심어주기에 충분했다.
하지만 모든 일이 항상 좋은 점만 있는 것은 아니다. 일반인들에게 당연한 기능 내지는 축복이 시선을 달리해서 개발자의 입장으로 바라보면 재앙과도 같기 때문이다. 단순히 동시에 실행된다는 사실 만으로는 그렇게 많은 문제가 발생하진 않지만 동시 상황에서 무엇인가를 공유하기 시작하면 지옥 문을 여는 것과 동일한 효과를 가져온다. 단순히 하나만 공유해도 이런데 프로세스 내에서 모든 자원을 공유하는 스레드는 문제 발생의 정도가 더 심각하다. 이런 환경은 개발자에게 엄청나게 많은 문제들을 안겨주었고, 더불어 기존에는 고민할 필요도 없었던 문제들에 대해서 고려하도록 만들었다. 그리고 이 시점부터 더 이상 프로그램은 결정론적으로 동작하지 않게 되었다. 이는 달리 표현하면 재현 불가능한 무수한 버그들이 스레드라는 판도라의 상자를 통해서 세상으로 출현했다는 것이다.
이런 여러 이유로 멀티 스레드 프로그래밍은 윈도우 프로그래밍 중에서도 매우 어려운 부분에 속한다. 그럼에도 점점 더 PC의 코어 수는 늘어가고 멀티 스레드 프로그래밍은 점점 더 당연한 일이 되어가고 있다. 왜 멀티 스레드 프로그래밍이 어려운 것일까? 아마도 우리의 생각 자체가 병렬적으로 동시 처리하는 것에 익숙하지 않은 점에 그 근본적인 원인이 있을 것 같다. 이번 시간에는 컴퓨터에서 멀티태스킹을 구현하는 기본적인 구조와 멀티태스킹이 가져오는 동기화 문제에 대해서 살펴보도록 하자.
시분할
요즘은 CPU가 여러 개 달린 컴퓨터가 일반적이다. 스마트폰에도 듀얼 코어가 탑재되는 세상이니 PC는 두 말하면 잔소리다. 이렇게 CPU가 여러 개 달린 컴퓨터에서는 동시에 실행하는 것이 하나도 이상하지 않다. 장치 개수만큼 동시에 실행할 수 있는 것은 누가 봐도 당연한 일이기 때문이다. 하지만 Windows가 인기를 끌듯 말듯했던 Windows 3.1 시절부터 Windows XP가 등장하던 시점까지는 이러한 멀티 코어가 일반적인 PC 환경은 아니었다. 그 당시 컴퓨터에는 CPU가 하나라는 것이 거의 고정적이었던 시절이었다. 그렇다면 어떻게 이 CPU가 하나인 환경에서 여러 개의 프로그램을 동시에 실행할 수 있었던 것일까? 여기에는 약간의 눈속임이 있다.
눈속임을 쉽게 이해하기 위해서 우리가 공부를 하는 과정에 비유를 들어서 먼저 살펴보도록 하자. 텅 빈 책상이 있고, 책상 앞 의자에 우리가 앉아 있는 상황이다. 책상에는 국어, 영어, 수학 문제집이 놓여 있다. 이 문제집을 다 푸는 것이 오늘 우리의 할 일이다. 이 미션을 달성하는 방법에는 크게 두 가지 전략이 존재한다고 볼 수 있다. 하나는 국어 문제집을 다 풀고, 영어 문제집을 다 풀고, 수학 문제집을 다 푸는 형태로 순차적으로 하나씩 작업을 완료해 나가는 방식이고, 다른 하나는 국어, 영어, 수학 문제집을 번갈아 가면서 조금씩 풀어 나가는 방식이다. 멀티태스킹의 비밀은 바로 두 번째 방법에 있다. 문제집을 번갈아 가면서 푸는 상황에서 문제집을 교체하는 시간을 단축시키다 보면 어느 순간부터는 마치 우리가 세 개의 문제집을 동시에 풀고 있는 것처럼 느껴진다는 사실을 이용한 것이다.
운영체제는 여러 개의 프로그램을 동시에 실행하기 위해서 우리가 문제집을 풀었던 것과 동일한 방식으로 CPU에 프로그램 코드를 할당한다. 탐색기, 계산기, 그림판이 실행되어 있다면 Windows는 탐색기 코드를 조금 실행하고 계산기 코드를 또 조금 실행하고 이어서 그림판 코드를 실행하고는 다시 탐색기 코드를 실행하는 식으로 동시에 실행된 프로그램의 코드들을 잘게 나누어서 반복적으로 실행함으로써 마치 우리 눈에는 여러 개의 프로그램이 동시에 실행되는 것처럼 보이도록 만든다.
문제집을 번갈아 가면서 풀려면 문제집을 바꿀 때에 자신이 마지막으로 풀었던 문제가 어디인지를 기록해 두어야 한다. 그래야 나중에 다시 그 문제집을 풀 때에 어디서부터 풀어나가야 할지 기억할 수 있기 때문이다. 이렇게 마지막으로 풀었던 문제가 어떤 것이었는지를 기억하는 것과 마찬가지로 멀티태스킹에서도 이러한 것들이 필요하다. 작업을 이어가기 위해서는 중지했던 작업을 어디서부터 이어가야 할지를 알아야 하기 때문이다. 이렇게 작업을 이어가기 위해서 중단했던 시점의 기록을 “컨텍스트”, 작업들을 전환하는 것을 두고는 “컨텍스트 스위칭”이라고 표현한다.
문제집을 번갈아 가면서 푼다는 이 방법을 좀 더 살펴보면 재미있는 현상이 있다. 문제집을 너무 띄엄띄엄 번갈아 풀 경우에는 세 개를 동시에 푼다는 느낌이 없어지고, 문제집을 자주 번갈아 풀다 보면 특정 시점 이상부터는 문제집을 푸는 시간보다 문제집을 바꾸는 시간이 더 많아진다는 점이다. 이는 시분할을 사용한 멀티태스킹 시스템에서는 모두 발생하는 문제로 컨텍스트 교체 비용이 공짜가 아니라는 점을 보여준다. 따라서 운영체제는 멀티태스킹의 품질과 교체 비용을 고려해서 작업 교체 빈도를 적절하게 결정할 필요가 있다.
협력형 vs 선점형
앞선 국영수 문제집 풀이 예를 다시 생각해 보자. 여기서 우리가 문제집을 번갈아 푼다고 생각했을 때 이를 통제하는 방식 또한 크게 두 가지 형태로 나뉠 수 있다. 문제집을 푸는 당사자가 자유롭게 일정시간 한 문제집을 풀다가 다음 문제집을 푸는 형태로 번갈아 가면서 푸는 방식이 있을 수 있고, 다른 하나는 선생님이 일정시간마다 들어와서 다음 문제집을 풀도록 지시하는 방법이 있을 수 있다.
그렇다면 이 두 방식에는 각각 어떤 장단점들이 있을까? 우선 스스로 관리하는 방법을 생각해 보자. 이 방법의 장점은 효율성의 극대화에 있다. 문제를 푸는 당사자가 스스로 문제집을 교체하기 때문에 언제 교체하는 것이 가장 유리한지를 알 수 있다는 점이다. 다른 부가적인 장점으로는 선생님이라는 외부 존재가 필요하지 않다는 점을 들 수 있겠다. 이는 다시 말하면 비용이 절감된다는 말이다. 이에 반해서 두 번째 방법은 선생님이라는 추가 비용이 발생하지만 문제집 교체가 일정시간마다 반드시 발생하기 때문에 문제집 세 개에 할당되는 시간이 비슷해 진다. 멀티태스킹의 관점에서 보자면 일정 수준 이상으로 멀티태스킹 품질을 유지할 수 있다는 것을 의미한다.
이게 바로 협력형과 선점형 멀티태스킹의 차이다. 협력형 멀티태스킹이란 멀티태스킹 작업들이 협력해서 동시에 실행되는 구조를 말한다. 프로그램 실행 중간에 스스로 컨텍스트 전환을 해도 되는 시점이라고 판단이 된다면 다음 작업에게 CPU를 양보해주는 형태로 멀티태스킹이 진행된다. Windows 3.1이 이러한 방식을 채택했었다. 하지만 앞서도 언급했듯이 이 방식의 단점은 특정 작업이 지나치게 오래 CPU를 독점할 경우 전체 멀티태스킹의 품질이 떨어질 수 있다는 점에 있다. 더욱 치명적인 문제는 특정 작업의 버그로 인해서 CPU 자원을 다른 작업에게 양보하지 않을 경구 운영체제 자체가 다운될 수 있다는 점이다.
선점형 멀티태스킹이란 협력형과는 반대로 작업 제어 권한을 커널이 가지는 구조를 말한다. 이는 모든 작업이 임의의 시점에 커널에 의해서 선점될 수 있음을 의미한다. 선점된다는 말은 CPU 자원이 커널에게로 다시 넘어간다는 것이다. 여기에선 특정 작업이 CPU를 독점할 수도 없으며, CPU를 독점하려 한다고 해도 커널이 원하면 언제든지 CPU 제어권을 뺏어올 수 있다. 즉, 커널이 선생님의 역할을 한다고 할 수 있다. 이 방식의 장점은 앞에서도 설명한 것과 같이 안정적으로 멀티태스킹을 수행할 수 있으며, 작업들의 버그로 시스템다운이라는 치명적인 결과가 나타나진 않는다는 점이 있다. Windows 95 이후의 모든 Windows 운영체제는 이러한 선점형 멀티태스킹 방식으로 멀티태스킹을 구현하고 있다.
스레드
전통적으로 운영체제에서 작업이란 프로세스란 개념으로 표현된다. 따라서 운영체제는 앞서 설명한 것처럼 이런 프로세스를 동시에 여러 개 실행할 수 있도록 만들기 위해서 프로세스의 코드를 번갈아 가면서 수행한다. 이렇게 멀티태스킹 운영체제를 만들어놓고 보니 만들어진 프로세스들 중에는 다른 프로세스와 대규모의 자료를 공유해야 하는 것들이 있었다. 이런 프로세스들은 단순히 자료를 공유하기 위해서 운영체제의 IPC라는 복잡하고 비싼 메커니즘을 사용하고 있었다. 여기에서 이런 의문을 품은 사람들이 생겨난다. 왜 이렇게 멍청하게 하고 있지? 처음부터 모든 것을 자동으로 공유하도록 해준다면 어떨까? 이런 생각이 스레드라는 개념의 초기 발상이다. 많은 유닉스 운영체제는 이 개념의 생각대로 스레드를 특수한 형태의 프로세스로 처리한다. 실제로 리눅스의 경우에는 스레드라는 별도의 커널 객체를 두기 보다는 단순히 동일한 커널 구조체를 참조하는 특수한 프로세스로 구현했다.
하지만 Windows는 이러한 방식이 아닌 좀 다른 방식으로 스레드와 프로세스의 관계를 정리했다. 일단 기존의 스케줄링 단위를 프로세스에서 스레드로 바꾸어 버렸고, 프로세스는 스레드가 수행될 때 참조하는 컨테이너 역할로 만들어버렸다. 이렇게 만들어버리자 Windows에서는 스레드가 없는 프로세스란 아무런 의미가 없어졌다. 그래서 Windows에서 모든 스레드 종료란 프로세스의 종료를 의미한다.
다시 정리하면 이렇다. Windows 환경에서 프로세스란 실행 환경을 제공해주기 위한 컨테이너 역할을 한다. 여기에는 주소 공간과 접근 권한 같은 것들이 포함된다. 프로세스 자체로는 수행 가능한 객체가 아니며 내부에서 하나 이상의 스레드를 수행시킬 수 있는 환경에 불과하다. 스레드는 스케줄링의 기본 단위이며 하나의 실행 흐름을 나타낸다. 스레드는 수행되기 위해서는 해당 스레드가 수행될 프로세스 정보가 있어야 한다.
동기화
스레드가 이야기가 나오면 기본 안주로 함께 등장하는 메뉴가 동기화 문제다. 왜 그럴까? 그 답을 알기 위해서는 “동기화란 무엇인가?” 라는 질문에 대한 답을 먼저 찾아야 한다. 동기화란 바로 코드가 실행되는 순서를 정하는 일을 말한다. 어떤 코드를 먼저 실행할지 어떤 코드를 나중에 실행할지 순서를 정하는 일이다. “프로그램은 항상 프로그래머가 정해둔 순서대로 실행되는 것 아닌가요?”라는 의문을 가질 수 있다. 맞다. 프로그램은 항상 프로그래머가 정해둔 순서대로 실행된다. 하나만 실행될 때에는 말이다. 하지만 동시에 실행되기 시작하면 문제가 달라진다. 개별 실행 흐름들은 기존과 마찬가지로 순차적으로 수행되지만 그 흐름들이 섞이면 전혀 생각지도 못한 문제를 발생시킨다. 이를 방지하기 위해서 섞여서 실행되는 코드들 사이에 순서를 정하는 일이 필요해진 것이다. 그래서 동기화 문제란 항상 동시성과 같이 나올 수 밖에 없다.
동시성 vs 병렬성
비슷해 보이는 이 두 말은 큰 차이를 가지고 있다. 우선 동시성이란 우리에게 동시에 실행되는 것처럼 보이는 것들을 모두 나타내는 말이다. 따라서 단일 CPU 상에서 시분할 기법을 사용해서 여러 개의 프로그램을 수행하는 것도 동시성에는 포함된다. 반면 병렬성이란 복수의 CPU를 통해서 코드가 진짜 동시에 실행되는 상황을 나타낸다. 이런 의미에서 병렬성이란 물리적인 동시성을 나타낸다고 생각하면 되겠다.
<리스트 1>에는 동기화 문제를 보여주는 간단한 코드가 나와 있다. PinGame 함수는 핀을 서로 하나씩 가져가면서 마지막 핀을 가져가는 사람이 이기는 게임을 묘사한 코드다. 이제 이 함수가 스레드로 여러 개가 동시에 실행된다고 가정해보자. 그러면 개별 스레드는 이 하나의 코드를 순차적으로 실행하겠지만 운영체제 관점에서 보자면 이 코드들이 섞여서 동시에 실행되는 셈이 된다. 이렇게 동시에 실행되면 하나만 실행될 때에는 전혀 생각지도 못했던 문제가 발생하는데 이렇게 동시에 실행되면서 발생하는 문제를 동기화 문제라고 말한다.
우선 동기화 문제를 보다 효과적으로 설명하기 위해서 간단한 표현식을 정의하자. “(숫자)”는 숫자에 해당하는 스레드로 컨텍스트가 변경됐음을 나타낸다. a, b와 같은 기호는 코드 상의 주석에 나타나 있는 지점이 실행됐음을 의미한다. 따라서 “(1)ab”라는 것은 1번 스레드가 a, b라는 코드를 실행했음을 나타낸다. 마찬가지로 “(1)ab(2)a”라는 것은 1번 스레드가 a, b를 수행하고 2번 스레드로 컨텍스트 전환돼서 a라는 코드가 실행됐음을 나타낸다.
리스트 1 동기화 문제를 가진 코드
int pin = 2;
void PrintWinner()
{
for(;;)
{ // ... a
if(pin == 1) // ... b
{
--pin; // ... c
printf("you win\n"); // ... d
break;
}
else
--pin; // ... e
}
}
자 그렇다면 앞서 정의한 표현식을 토대로 <리스트 1>의 코드에서 동기화 문제가 발생하는 흐름을 찾아보도록 하자. 바로 “(1)abea(2)ab(1)bcd(2)cd”가 그것이다. 문제는 결국 “you win”이라는 문자열이 화면에 두 번 출력된다는 것이다. 이런 코드들을 보여주고 여기까지 설명하면 대부분의 학생들은 <리스트 1>의 코드를 고침으로써 문제를 해결할 수 있다고 생각한다. 그리고는 거의 대부분 코드를 <리스트 2>와 같이 고친다.
리스트 2 동기화 문제를 가진 코드
int pin = 2;
void PrintWinner()
{
for(;;)
{
if(pin <= 0)
break;
--pin; // … a
if(pin == 0) // … b
{
print("you win\n");
break;
}
}
}
그렇다면 과연 <리스트 2>의 코드는 문제가 없을까? 언뜻 보기에는 뭔가 좀 더 명확해 보여서 문제가 없어 보이지만 좀 깊이 생각해보면 <리스트 2>의 코드도 문제가 생기는 흐름을 찾아낼 수 있다. “(1)a(2)abc(1)bc”가 그것이다. 이 코드는 동기화 문제가 어디에서 비롯되는지 보다 명확하게 보여준다. 바로 값의 변경과 비교 사이에 발생하는 컨텍스트 전환이다. 이 말은 pin의 값 변경과 0과 비교하는 부분이 하나의 덩어리로 뭉쳐서 연산이 되지 않고서는 절대로 이 문제를 해결할 수 없다는 걸 의미한다.
여기까지 이야기하면 “if(--pin == 0)”과 같은 코드를 쓰면 된다고 생각하는 사람들이 있다. 하지만 “if(--pin == 0)”과 같은 표현식도 C언어 상으로는 한 줄로 표기되기 때문에 한번에 실행된다고 생각하기 쉽지만 컴파일러가 생성해낸 기계어는 <리스트 2>의 코드와 마찬가지로 여러 개의 기계어로 구성되어 있기 때문에 실행하는 사이에 컨텍스트 스위칭이 발생할 수 있고, 최종 프로그램에서는 동일한 문제가 발생한다.
이를 해결하기 위해서는 앞에서 우리가 내린 결론, 값의 변경과 비교가 한 덩어리가 되어서 실행되도록 만들어줄 어떤 도구가 필요하다. 이런 기능을 제공해주는 도구가 바로 운영체제에서 지원해주는 동기화 객체다. <리스트 3>에는 이러한 도구 중 하나인 크리티컬 섹션을 사용해서 문제를 해결한 코드가 나와있다.
리스트 3 크리티컬 섹션을 통한 동기화 문제 해결
CRITICAL_SECTION cs;
EnterCriticalSection(&cs); // … a
--pin; // … b
if(pin == 0) // … c
{
LeaveCriticalSection(&cs); // … d
print("you win\n"); // … e
break;
}
LeaveCriticalSection(&cs); // … f
크리티컬 섹션은 해당 객체를 진입한 상태에서 다른 스레드가 다시 진입하지 못하도록 만드는 기능을 한다. “(1)a(2)”는 가능하지만 “(1)a(2)a”는 불가능하다는 말이다. “(1)a(2)(1)b(2)(1)c(2)”와 같이 1번 스레드가 a를 수행하고 나면 1번 스레드가 d를 수행하기 전까지 다른 스레드로의 컨텍스트 스위칭은 발생할 수 있지만 해당 스레드가 a를 수행 시키는 것은 불가능하다. 결국 실행 흐름의 입장에서는 중간 중간 잡음이 섞이긴 하지만 결과론적으로 b와 c가 항상 한번에 수행되는 것과 동일한 효과를 나타낸다.
그렇다면 운영체제는 어떻게 이런 것들을 만들 수 있었을까? 운영체제가 만든 코드라고 하더라도 결국 CPU 상에서 동작하기는 우리가 만든 코드와 다를 수 없기 때문이다. EnterCriticalSection 내부에서도 언제든지 컨텍스트 전환이 발생할 수 있지 않을까? 당연한 이야기다. 이 문제를 해결하기 위해서는 크게 두 가지 종류의 해법이 있다. 하나는 운영체제 스케줄러를 특수하게 만들어서 EnterCriticalSection과 같은 함수의 수행 도중에는 절대로 컨텍스트 전환이 발생하지 않도록 만드는 것이다. 다른 하나는 보다 낮은 단계에서 값의 변경과 비교를 한번에 수행하는 연산을 사용하는 것이다. x86/64 계열의 CPU에는 이러한 목적을 위해서 cmpxchg라는 명령어를 제공한다. 비교와 변경을 한번에 수행하는 명령어다. Windows는 결국에는 이러한 명령어를 사용해서 동기화 객체를 구현한다. 이런 맥락에서 우리가 처음에 만든 프로그램도 cmpxchg라는 명령어를 사용하도록 만든다면 동기화 객체를 사용하지 않고도 문제를 해결할 수 있다.
지금까지의 논의는 단일 CPU 상에서 동시에 실행되는 코드들에 대한 것이었다. 멀티코어가 되면 문제가 좀 더 복잡해진다. 멀티코어란 CPU가 두 개 이상인 시스템을 말한다. 이런 시스템에서는 진짜 동시 실행이 가능해진다. 이 말은 지금까지 논의했던 방식으로는 멀티코어 환경에서 문제를 해결할 수 없다는 것을 의미한다. 앞서 동기화 문제를 해결하는데 일등 공신이었던 cmpxchg라는 명령어를 생각해보자. n개의 CPU가 있다고 가정한다면 n개의 CPU에서 동시에 cmpxchg가 실행될 수 있다. 결국 문제를 해결하기 위해서는 cmpxchg라는 명령어가 n개의 CPU가 아닌 한번에 하나의 CPU에서만 실행될 수 있도록 만들어주는 메커니즘이 필요하다. x86/64 계열의 CPU에서는 lock이라는 접두어가 이런 역할을 한다. lock cmpxchg를 하면 해당 시점에는 한 녀석만 메모리에 접근하도록 만들어준다. 재미있는 사실은 동시성에서 비롯된 동기화 문제를 해결하기 위해서는 결국 동시에 실행되지 않는 무엇인가가 필요하다는 점이다.
컨텍스트, 컨텍스트, 또 컨텍스트
스레드 이야기에는 필연적으로 다양한 종류의 컨텍스트라는 말이 등장한다. 의미는 다른데 이를 표현하는 말은 모두 컨텍스트이기 때문에 처음 접하는 개발자는 상당히 혼란스러울 수 있다. 여기에서 잠깐 이런 컨텍스트라는 말의 정체에 대해서 알아보고 넘어가도록 하자. 컨텍스트를 우리 말로 번역하면 문맥이라는 말이 된다. 문맥이란 무엇인가? 우리가 일상생활에서 접하게 되는 문맥이라는 말의 의미는 다음과 같은 간단한 대화 속에서 쉽게 파악할 수 있다.
영희: 너 밥 먹었니?
철수: 응. 먹었어.
일반적으로 구두 표현은 상황을 통해 유추할 수 있는 정보는 대부분 삭제된다. 우리는 철수의 대답에는 포함되지 않았지만 상황을 통해 ‘먹었어’라는 말을 철수가 밥을 먹었다라는 사실로 자연스럽게 확장시킬 수 있다. 이렇게 유추의 근거를 제공하는 말이 놓인 상황을 우리는 문맥이라고 부른다. 프로그래밍에서 말하는 것 또한 이와 별반 다르지 않다. 일반적인 프로그래밍 환경에서 말하는 컨텍스트라는 말은 해당 코드가 바인딩되어 실행되는 환경을 일컫는다. 물론 이렇게 이야기를 하더라도 바인딩되어 실행되는 환경이라는 것이 무엇인지 감이 잡히지 않는다. 이를 좀 더 구체적으로 살펴보기 위해서 <리스트 4>의 코드를 살펴보자.
리스트 4 콜백 함수를 사용한 프로그램
std::list GlobalList;
void Callback()
{
GlobalList.push_back(GetTickCount());
}
int main()
{
RegisterCallback(Callback);
for(;;)
{
if(!GlobalList.empty())
{
cout << GlobalList.front(); << endl;
GlobalList.pop_front();
}
}
return 0;
}
<리스트 4>의 코드에서 RegisterCallback 함수는 입력으로 들어간 콜백 함수가 랜덤 주기로 호출되도록 등록하는 함수다. 만약 RegisterCallback 함수 설명에 ‘입력으로 들어온 콜백 함수는 등록한 스레드 컨텍스트에서 호출된다’라는 말이 있다면 위 코드는 아무런 문제 없이 동작한다. 하지만 이와는 다르게 ‘입력으로 들어온 콜백 함수는 시스템 스레드 컨텍스트에서 호출된다’는 말이 있다면 위 코드는 잘못 동작한다. 이유는 Callback이 호출되는 스레드와 main이 실행되는 스레드가 다르기 때문이다. 서로 다른 스레드가 GlobalList를 동시에 접근함에도 동기화 처리가 되어 있지 않기 때문에 최악의 경우에는 프로그램 크래시가 발생한다. 이와 같이 Callback이라는 코드가 바인딩되어 실행되는 스레드를 우리는 컨텍스트라는 말로 표현한다.
여기서 더불어 살펴보아야 할 표현이 하나 있다. 바로 “임의의 스레드 컨텍스트”라는 표현이다. 대부분의 콜백 함수 설명에는 등록한 콜백 함수는 임의의 스레드 컨텍스트에서 호출된다라는 말이 있기 때문이다. 여기서 “임의의”라는 말은 정해지지 않았음을 나타내는 말이다. 그러니 “임의의 스레드 컨텍스트”라는 말은 달리 표현하면 “정해지지 않은 스레드 컨텍스트”가 된다. 그렇다면 도대체 정해지지 않은 스레드 컨텍스트라는 표현이 개발자에게 해주고 싶은 속내는 무엇이었을까? “정해지지 않았음”이 프로그래머에게 해주고 싶은 진짜 이야기는 이 말이다. “네 멋대로 특정 스레드 컨텍스트에서 호출된다고 생각하지 말아라. 이 코드는 네가 생각지도 못한 스레드 컨텍스트에서도 호출될 수 있다.”
“해당 콜백 함수는 임의의 스레드 컨텍스트에서 호출된다”는 문장을 보는 순간 여러분이 베스트 개발자라면 다음과 같은 생각을 반드시 해야 한다. “아, 이 함수가 실행되는 스레드는 정해지지 않았구나. 콜백 함수가 전역 데이터에 접근하는 경우에는 반드시 동기화 처리를 해줘야 하겠구나.” 반대로 “해당 콜백 함수는 콜백을 등록한 스레드와 같은 컨텍스트에서 호출된다”는 문장을 보게 되면 역으로 콜백과 등록한 스레드에서만 사용하는 공유 데이터가 있다면 동기화 처리를 생략해서 최적화 작업을 할 수 있겠다라는 생각을 하는 것이 정석이다.
지금까지 특정 함수가 수행되는 컨텍스트에 대해서 알아보았다. 이와 마찬가지로 특정 스레드도 해당 스레드가 수행되는 컨텍스트가 존재한다. 바로 프로세스다. 앞서 Windows에서 스레드의 개념을 설명할 때 특정 프로세스 내에서 수행되는 실행 흐름이라고 표현했다. 따라서 스레드가 동작하기 위해서는 반드시 어떤 프로세스 컨텍스트에서 동작할지를 알아야 한다는 말이다. 일반적으로 프로그램에서 생성하는 모든 스레드는 그 코드를 수행하는 스레드가 참조하는 프로세스 컨텍스트로 생성된다. 하지만 커널 모드 코드들의 경우에는 이 참조하는 프로세스 컨텍스트를 임의의 변경할 수 있다. 따라서 코드가 호출되는 스레드 컨텍스트와 마찬가지로 스레드가 수행되는 프로세스 컨텍스트도 변경될 수 있음을 알아야겠다.