지난 게시글.
[OS/OSTEP] 26.threads-intro
# 글을 시작하며
얼마전에 운영체제 중간고사를 위해서 벼락치기로 글들을 정리했던 기억이 있다. 그때 3일만에 17개 글을쓰며 개념을 정리해나갔는데 큰 도움은 됐지만 정신적으로 너무 힘들었다. 그래서 이번에는 조금 시간적 여유를 두고 정리하려고 한다. 저번 중간고사에서는 92.5 / 100 점수를 받았다. 아는 부분에서 틀린게 많아서 아쉽지만 그마저도 실력이라는 생각이 든다. 이번에는 조금더 차근차근 공부해보려고 한다. 물론 중간고사 시험범위 시작부분부터 정리할 것이지만 난 거의 후반부까지 수업내용을 들은 상태이다. 그러나 헷갈리는 개념이 너무나도 많고 뒷부분은 진짜 너무 헷갈리기때문에 열심히 해야할 것 같다.
중간고사는 가상화된 cpu와 가상화된 메모리가 주된 내용이었다면, 기말고사 범위에는 쓰레드에 대해서 다룬다.
# 개요
프로그램에서 한 순간 하나의 명령어(Program Counter)만 읽었다면, 이제는 멀티 쓰레드 프로그램을 배우며 여러개의 PC값을 읽게 되는 경우를 살펴 볼 것이다. 프로세스와 쓰레드는 비슷하면서도 다른데, 프로세스는 새로운 메모리 영역이 잡힌다면, 쓰레드는 이미 잡힌 메모리 영역에서 새로운 스택하나만을 배정받아 사용한다. 그말은 즉슨 전역변수들은 여러쓰레들간의 같은 메모리참초를 공유한다느 것이다. 이것이 뒤에가면 문제를 읽으키게 되는데 하나씩 천천히 살펴보면 좋을것 같다. 쓰레드 역시 문맥교환을 통해서 실행 중인 쓰레드와 교체되어야 한다. 프로세스에서는 Process Control Bloc(PCB)을 사용했다면, 쓰레드에서는 Thread Control Block(TCB)를 사용한다.
위 그림과 같이, 단일 쓰레드는 하나의 스택이 메모리 영역에 존재 했다면, 멀티 쓰레드는 쓰레드별로 생성되는 여러개의 스택이 메모리 영역에 잡히게 된다.
# 쓰레드 생성해보기
먼저 생성된 쓰레드를 담을 주소공간 p1,p2를 잡아준 후, pthread_create()로 쓰레드를 생성하여 인자에 넘겨준 값에 배정해준다. mythread는 생성되는 쓰레드가 실행할 공간이며, "A" 와 "B"는 인자로 넘어가 그 기능을 하게 된다.
pthread_join은 해당 쓰레드가 종료되기를 기다리며, 리턴이 된다면 다음절이 시작된다. 한가지 주의해야할 점은, 파이써닉하게 마치 스크립트 언어처럼 pthread_create()를 호출한다고해서 해당 쓰레드가 즉시 실행되는 것은 아니다. 스케줄러가 해당 사항을 결정하기 때문에 우리는 모른다. 그렇기 때문에 다양한 경우가 펼쳐질 수 있다. 아래 표를 봐보도록 하자.
첫번째는 쓰레드1과 쓰레드2의 생성이 완료된 후에, 차근차근하게도 쓰레드1이 실행되고 쓰레드2가 실행되는 경우이다.
아래 표는 쓰레드1이 생성되자말자 쓰레드가 실행되고, 쓰레드2도 생성되자말자 실행됨을 알 수 있다. 이외에도 스케줄러가 결정하는 많은 방법으로 각각의 쓰레드가 언제 시작될지 결정 될 것이다.
아래 표와 같이 역으로 쓰레드2가 먼저 실행되는 경우도 존재 할 수 있다는 말이다.
# 쓰레드가 전역변수를 통해 데이터를 공유한다면 ?
쓰레드가 뭔지 알았다면, 가장 먼저 드는 생각은 쓰레드를 어디에 쓸 수 있냐는 것이다. 당연하게도 나는, 어떠한 일을 '두개의 쓰레드에서 나눠서 할 수 있지 않을까?' 였다. 반복문을 통해 200만까지의 카운트를 해야한다면, 두개의 쓰레드로 나누면 100만씩 둘이 나뉘어 실행된다면 좋을 것 닽다. 이 예시가 전형적인 전역변수를 공유하는 경우인데 과연 결과가 어떠한지 살펴보도록 하자.
쓰레드를 생성한다. 각각의 쓰레드는 mythread를 실행할 것이고, 10,000,000 전역변수인 counter에 반복문을 돌며 더해나가야 할 것이다. 그렇다면 우리의 결론은 두개의 쓰레드가 종료 됐다면 counter의 값은 200만이 되어야 한다는 것이다.
그러나 실제로 실행된 결과를 봐보면 그렇지 않다.
위와 같이 200만보다 작은 알수 없는 숫자가 나온다. 또 다시 실행해본다면 다른값이 나올 것이다. 왜 이런일이 벌어질까? 우리가 원하는 결과는 아닌건 확실하다.
counter = counter + 1 의 연산은 어셈블리어로 즉 하드웨어가 이해하는 코드로는 아래와 같다.
counter(0x8049a1c)값을 eax레지스터에 저장시킨후, 그 레지스터의 값에 1을 더한다. 그리고 eax레지스터의 값을 다시 counter(0x8049a1c)에 저장한다. 즉 우리가 작성한 한줄의 코드는 사실 이 3개의 구문이 실행되는 것이다. 그렇다면 어디서 문제가 발생 했던 것일까?
초기 0x8049a1c에는 1의 값이 있다면 먼저 T1과 T2의 쓰레드가 각각 생성되어 3줄의 명령어를 실행해야 할 것이다. 먼저 T1쓰레드가 실행되어 (a)까지 실행을 마친다면 레지스터의 0x8049a1c에 들어있는 값의 +1 (== 2) 이 되어 있을 것이다. 아직 메모리에 더해진 값을 저장하진 않았기 때문에 바뀌기전 값(1)이 그대로 있을 것이다. 그사이에 인터럽트가 걸려 T2가 그대로 실행되어 3줄의 수행을 마무리한다. 여전히 0x8049a1c에는 1의 값이었기 때문에 그 값을 그대로 불러와 1을 더해 얻은 2의 값을 0x8049a1c에 저장한다. 그리고 다시 타임아웃이 걸려 TCB에 의해서 T1이 잘렸던 단면을 이어 실행한다. T1은 1의 값을 이미 불러와 1을 저장시켜 얻은 2의 값이 들어있는 상태이고 메모리에 저장만 시키면 된다. 이제 2가 저장 될 것이다.
이상하게도 counter에 1을 올려주는 코드가 T1과 T2에서 실행됐기 때문에 예상 결과는 3을 얻어야 하지만, 2라는 값을 얻어버렸다. 두개를 실행했지만 하나만 실행한거와 같은 경우가 되버린 것이다.
이제 위와 같은 행동을 race condition(경쟁 조건)이라고 칭한다. 두개가 하나를 놓고 경합하며 실행순서에 따라 값이 바뀌는 비결정적인 결과를 야기시킨다.
그리고 이러한 경쟁조건이 발생하는 부분의 코드를 critical section(임계 영역)이라고 한다. 즉 동시에 실행되어 경쟁조건이 야기되면 안되는 부분의 코드이다.
이러한 코드는 mutual exclusion(상호 배제)를 통해 임계영역의 코드가 실행중일때는 다른 쓰레드에서 실행 할 수 없도록 해야한다.
# Atomic - 원자성
원자는 양성자 중성자 전자 등으로 쪼개지지만, 이 용어가 등장할때까지만 해도 더이상 쪼개지지 않는 가장 작은 단위라고 여겨졌었다고 한다. counter = counter + 1는 3개의 연산이 수행됐어야 했다.
가장 근본적인 해결책은 저 사이에 인터럽트가 걸리지 않도록하기 위해 3개를 한개로 붙여버리는 것이다.
그렇다면 인터럽트가 걸리더라도, 실행전이거나 실행 후일것이기 때문이다. 그런데 이 것은 말도 안되는 소리이다. 아니 말은 되지만, 모든 코드에서 통용될 수 없다. 내가 쓰레드를 이용해서 엄청 긴 코드를 작성헀는데 그걸 어셈블리어로 한줄코드로 만드는건 상상으로만으로 어렵고 끔찍한 작업이 될 듯 하다. 지금은 도입부이지만 뒤로 가서는 이 현상을 lock을 이용하여 해결한다.
# 기다리기
공유변수접근 문제가 있었다면, A쓰레드가 B쓰레드의 동작을 끝날때까지 기다리다가 끝나면 실행해야하는 경우도 존재 할 ㄱ서이다. 이런 경우 while문을 이용한 spin기법을 사용하기도하고 wait를 통해서 재우기도 한다. 또 이렇게 자는 쓰레드를 관리하기 위해 condition variable(컨디션 변수)를 이용하여 마치 큐처럼 꺠울 쓰레드를 관리하기도 한다.
# 마치며
시작부분이라 지금 정리하다보니까 쉬운감이 있다. 그러나 처음 이 부분을 공부할때는 무척이나 헷갈렸던 것 같다. 사실 중간부분가면 내 기준 매우 헷갈렸는데, 잘 정리해보도록 하겠다.