운영체제, lock에 관해 알아보자 1편 - lock이 필요한 이유
[ 프로세스의 경량화, 쓰레드 ]
여러 설명이 있지만 프로세스와 쓰레드의 차이는 정보 전송 비용이라고 생각한다. 학교에서 운영체제나 운영체제 실습을 수강해보았다면 c언어를 배우면서 멀티 프로세스 환경에서 서로 데이터를 주고 받는 과제를 해본 적이 있을 것이다. 프로세스끼리는 공유하는 자원이 없기 때문에 서로 데이터를 주고 받기 위해서는 서버와 클라이언트 역할을 하는 프로세스를 따로 두고 소켓을 열어서 데이터를 주고 받아야 한다. 이 과정은 굉장히 복잡하고 신경쓸게 많다.
아무튼 동시 처리를 위해서 여러 프로세스를 사용하고 싶은데 정보 교환에 있어 부가적인 부하가 생기니 공유 자원을 두고 그런 부하없이 언제든지 가지고 올 수 있게 하자는 생각으로 만들었다고 생각한다. 그래서 쓰레드는 개별 스택 영역을 가지고 코드, 데이터, 힙 영역은 서로 공유를 한다.
이렇게 함으로써 여러 프로그램이 동시에 돌아갈 때 얻을 수 있는 장점은 두 가지가 있다. 앞의 언급처럼 공유 자원에 접근하기가 쉬워진다. 또한 컨텍스트 스위칭이 일어났을 때, 모든 내용을 저장하고 복구할 필요없이 스택 영역만 바꿔치기 해주면 되니 이 비용도 줄어든다.
[ 멀티 쓰레드 환경에서의 동시성 문제 ]
공유 자원을 만듦으로써 데이터 공유와 컨텍스트 스위칭을 가볍게 만들었지만 문제는 공유자원의 처리에 있다. Race Condition이라는 단어를 많이 들어봤을 것이다. Race Condition은 여러 쓰레드들이 공유하는 리소스인 Critical Section에 다수의 쓰레드들이 공유자원을 동시에 접근하기 때문에 발생한다.그 상황을 C로 재현하면 다음과 같다. C에 안 익숙한 사람을 위해 남기면, 아래의 코드는 main 부분에서는 쓰레드를 두 개 생성한 뒤 쓰레드가 끝날 때까지 기다렸다가 쓰레드가 종료되면 공유 변수인 카운터 값을 출력하는 코드이다. 각각의 쓰레드에서의 내용은 공유 자원인 counter를 100만번 더하는 처리를 한다.
#include <stdio.h>
#include <assert.h>
#include <pthread.h>
static volatile int counter = 0;
void *mythread(void *arg) {
printf("%s: begin\n", (char *) arg);
int i;
// 전역변수 counter를 1씩 100만번 증가시킵니다.
for (i = 0; i < 1000000; i++){
counter += 1;
}
printf("%s: done\n", (char *) arg);
return NULL;
}
int main(int argc, char *argv[]) {
pthread_t p1, p2;
printf("main: begin (counter = %d)\n", counter);
pthread_create(&p1, NULL, mythread, "A");
pthread_create(&p2, NULL, mythread, "B");
pthread_join(p1,NULL);
pthread_join(p2,NULL);
printf("main: done (counter = %d)\n",counter);
return 0;
}
코드 출처: https://icksw.tistory.com/155 [PinguiOS:티스토리]
이 코드를 실행해보면 알겠지만 200만을 기대할텐데 절대로 200만이 아닌 더 낮은 값이 나온다. 그 이유는 아래의 코드 부분의 내용처럼 사실 counter에 1을 더하는 행동이 원자적으로 동작하지 않기 때문이다. counter += 1이라는 코드는 사실 어셈블리어로 보면 counter 변수의 값을 레지스터에 옮기고 레지스터 값을 1 더한 다음, 레지스터의 값을 counter로 옮겨놓는 총 3개의 명령어로 나뉘어져 있다.
// 이 코드는 사실 c언어로 보면 원자적으로 행동할 것 같다.
counter += 1
// 하지만 어셈블리어로 보면 3개의 명령어로 나뉜다.
mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
3개로 나뉘어져있는데 그게 뭐 어떤 문제인데?라고 생각할 수 있다. 개인적인 생각이지만 동시성과 관련해서는 정말 치졸하게 생각을 해야지 잘 이해가 된다. 정말 치졸하게 쓰레드 1번과 2번이 있는데 1번 쓰레드의 eax레지스터에 값을 가져오고 더한 다음 컨텍스트 스위칭이 일어났다고 해보자. 그리고 2번에 가서 주소에 있는 값을 가져오고 1을 더한 다음 반영한 뒤 다시 1번으로 돌아오자. 그러면 1번 쓰레드에서는 eax 내용이 불러오고 다음 명령어인 eax 레지스터에 있는 값을 counter에 반영한다. 그런데? 1번 쓰레드의 eax 레지스터에 담겨있는 값은 2번 쓰레드가 1을 더하고 반영하기 전의 값이다. 그러니 결과적으로 2번의 카운팅이 일어나야 하는 상황에 1번의 카운팅만이 반영된 것이다. 이런 상황이 항상 일어나지는 않았겠지만 100만 번 더해지는 동안 계속 일어났던 것이고 그러니 200만이 결과값으로 나오지 않았던 것이다.
[ Lock ]
위에서 언급한 멀티 쓰레드 환경에서의 공유자원과 컴퓨터 구조 때문에 race condition이 생기는 걸 방지하려면 어떻게 하면 될까? 그 문제는 Lock을 통해서 해결할 수 있다. Lock은 위의 상황에서 counter += 1;과 같이 공유자원을 변경하는 임계영역에 외부에 걸어주면 된다. mutex 코드를 찾아보고 위의 코드를 바꿔봤는데 동작이 될 지는 잘 모르겠다. 아무튼 아래처럼 임계영역을 lock으로 설정해놓으면 마치 DB에서 여러 SQL이 하나의 트랜잭션으로 묶이면 원자적으로 동작하는 것처럼 counter += 1이 원자적으로 동작하는 걸 보장해준다. 따라서 앞에서 나온 문제를 해결할 수 있다!
#include <stdio.h>
#include <assert.h>
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static volatile int counter = 0;
void *mythread(void *arg) {
printf("%s: begin\n", (char *) arg);
int i;
// 전역변수 counter를 1씩 100만번 증가시킵니다.
for (i = 0; i < 1000000; i++){
pthread_mutex_lock(&mutex);
counter += 1;
pthread_mutex_unlock(&mutex);
}
printf("%s: done\n", (char *) arg);
return NULL;
}
int main(int argc, char *argv[]) {
pthread_t p1, p2;
printf("main: begin (counter = %d)\n", counter);
pthread_create(&p1, NULL, mythread, "A");
pthread_create(&p2, NULL, mythread, "B");
pthread_join(p1,NULL);
pthread_join(p2,NULL);
printf("main: done (counter = %d)\n",counter);
pthread_mutex_destroy(&mutex);
return 0;
}
다음 글에서는 어떻게 lock을 구현하고 활용하는 지에 관해서 알아볼 예정이다.
[ 참고 자료 ]
https://gmlwjd9405.github.io/2018/09/14/process-vs-thread.html