시스템 프로그래밍 최종장: 프로세스(Process)와 스레드(Thread)💻⚙️
우리가 작성한 코드가 실행되는 순간, 그건 더 이상 단순한 텍스트 파일이 아니다. 운영체제라는 거대한 시스템 안에서 자신만의 자원과 실행 흐름을 가진 독립적인 존재, 바로 프로세스(Process)로 거듭난다. 그리고 이 프로세스 내에서 더욱 세밀하게 작업을 나누어 동시에 처리할 수 있게 하는 것이 스레드(Thread)이다. 이번 포스팅에서는 이 두 거인의 어깨 위에 올라타 시스템의 동시성(Concurrency)과 병렬성(Parallelism)이 어떻게 구현되는지, 그 비밀을 파헤쳐 보고자 한다.
1. 프로세스(Process): 실행 중인 프로그램의 모든 것
프로세스는 단순히 '실행 중인 프로그램'이라는 정의를 넘어, 시스템 프로그래밍 관점에서 다음과 같은 요소들을 포함하는 독립적인 실행 단위이다.
- 코드(Code) 영역: 실행할 프로그램의 명령어들이 저장된다.
- 데이터(Data) 영역: 전역 변수, 정적 변수 등 프로그램이 사용하는 데이터가 저장된다.
- 힙(Heap) 영역: 동적으로 할당되는 메모리 공간이다 (예:
malloc
,new
). - 스택(Stack) 영역: 함수 호출 시 지역 변수, 매개변수, 반환 주소 등이 저장된다.
- 운영체제 자원: 파일 디스크립터, 네트워크 연결 소켓 등 커널에 의해 관리되는 자원들의 집합.
- 프로세스 제어 블록 (PCB, Process Control Block): 프로세스의 상태(실행, 준비, 대기 등), 프로그램 카운터(PC), 레지스터 값, 스케줄링 정보, 메모리 관리 정보 등을 담고 있는 커널 내 자료구조.
Process Overview |
위와 같이 프로세스는 시스템에 의해 관리되는 실행 단위(execution unit)로 제시된다. 복수의 프로세스는 시스템 상에서 동시적으로(concurrently) 존재하며 실행될 수 있다.
-
Logical Control Flow (논리적 제어 흐름)
- 프로세스의 논리적 제어 흐름이란, 해당 프로세스가 CPU 상에서 실행될 때 따라가는 명령어들의 순차적인 경로를 의미한다. 각 프로세스는 자신만의 프로그램 카운터(program counter) 값을 가지고 있으며, 이는 다음에 실행될 명령어의 메모리 주소를 가리킨다.
- 시스템은 이러한 복수의 프로세스 간에 CPU 제어권을 전환하며 동시성을 구현하는데, 이를 컨텍스트 스위칭(context switching)이라고 한다. 컨텍스트 스위칭이 발생하면, 현재 실행 중인 프로세스의 문맥(CPU 레지스터 값, 프로그램 카운터 등)이 저장되고, 스케줄링된 다른 프로세스의 문맥이 복원되어 해당 프로세스의 논리적 제어 흐름이 재개된다. (이에 대해서는 동시 프로세스에 대해 알아보며 더 자세히 설명하겠다.)
- 프로세스의 실행 종료는
exit()
시스템 콜을 통해 명시적으로 또는 메인 함수의 반환 등을 통해 암묵적으로 발생하며, 이는 해당 프로세스의 논리적 제어 흐름이 끝났음을 의미한다.
-
Private Virtual Address Space (독립적인 가상 주소 공간)
- 프로세스들은 서로 독립적인 자원 관리를 한다. 이는 이전에 살펴보았던 현대 운영체제에서 각 프로세스가 자신만의 독립적인 가상 주소 공간(virtual address space)을 가진다는 개념과 밀접하게 연결된다.
- 프로세스의 독립성은 파일 디스크립터 테이블(Descriptor table)에서 드러난다. 각 프로세스는 자신만의 파일 디스크립터 테이블을 별도로 갖는다. 즉, 한 프로세스에서 특정 파일 디스크립터(예:
fd=3
)가 가리키는 파일은 다른 프로세스의fd=3
과는 다를 수 있다. - 반면, 하위 수준의 파일 관련 구조체들, 예를 들어 오픈 파일 테이블(Open file table)과 v-node 테이블(v-node table)은 모든 프로세스에 의해 공유된다. (이에 대해서도 아래에서 자세히 알아보겠다.)
Key Abstraction of Process |
1.1. 동시 프로세스 (Concurrent Processes) Concurrent Processes 🏃♂️🏃♀️
여러 프로세스가 동시에 실행되는 것처럼 보이는 상태를 의미한다. 실제로 CPU 코어가 여러 개인 멀티코어 환경에서는 여러 프로세스가 물리적으로 동시에 실행될 수 있고(병렬성, Parallelism), 단일 코어 환경에서는 운영체제의 시분할(Time-sharing) 기법을 통해 아주 짧은 시간 간격으로 여러 프로세스에게 CPU 사용권을 번갈아 할당함으로써 동시 실행을 흉내 낸다(동시성, Concurrency). 사용자 입장에서는 여러 프로그램이 동시에 매끄럽게 작동하는 것처럼 느껴진다.
User View of Concurrent Processes |
1.2. 문맥 전환 (Context Switching) 🔄
단일 CPU 환경에서 동시성을 달성하거나, 멀티코어 환경에서도 실행 중인 프로세스를 변경해야 할 때, CPU는 현재 실행 중인 프로세스 A에서 다른 프로세스 B로 실행 제어를 넘겨야 한다. 이 전환 과정을 문맥 전환(Context Switching)이라고 한다.
Context Switching |
- 과정:
- 저장: 현재 실행 중인 프로세스(A)의 상태(레지스터 값들, 프로그램 카운터, 스택 포인터 등 CPU 문맥)를 해당 프로세스의 PCB에 저장한다.
- 복원: 다음에 실행할 프로세스(B)의 PCB에서 문맥 정보를 CPU 레지스터로 복원한다.
- 프로세스 B의 프로그램 카운터가 가리키는 지점부터 실행을 재개한다.
- 오버헤드: 문맥 전환은 순수하게 커널 코드의 실행을 통해 이루어지며, 이 과정 자체는 사용자 프로그램의 작업과 무관한 오버헤드이다. 잦은 문맥 전환은 시스템 전체의 성능 저하를 유발할 수 있으므로, 운영체제 스케줄러는 문맥 전환 비용과 응답성 사이에서 적절한 균형을 찾아야 한다.
2. 프로세스 생성과 제어: fork()
, exit()
, 그리고 파일 공유
2.1. 새로운 생명 탄생: fork()
시스템 콜 🐣
Unix 계열 시스템에서 새로운 프로세스를 생성하는 가장 기본적인 방법은 fork()
시스템 콜이다.
-
int fork(void);
-
반환 값의 마법:
- 부모 프로세스에게는: 새로 생성된 자식 프로세스의 PID (Process ID)를 반환한다. (PID는 양의 정수)
- 자식 프로세스에게는: 0을 반환한다.
- 오류 발생 시: -1을 반환하고,
errno
에 오류 코드가 설정된다. 이 반환 값의 차이를 이용하여 부모와 자식 프로세스가 서로 다른 코드 경로를 따르도록 프로그래밍할 수 있다.
C#include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { pid_t pid; int x = 1; // 지역 변수 pid = fork(); // fork() 호출 if (pid < 0) { // 오류 처리 fprintf(stderr, "Fork Failed\n"); return 1; } else if (pid == 0) { // 자식 프로세스 영역 printf("Child PID is %d, my x before change = %d\n", getpid(), x); x++; // 자식의 x 값 변경 printf("CHILD: I am child process (PID: %d), my parent is %d. My x = %d\n", getpid(), getppid(), x); } else { // 부모 프로세스 영역 printf("Parent PID is %d, my x before change = %d\n", getpid(), x); x--; // 부모의 x 값 변경 printf("PARENT: I am parent process (PID: %d), my child is %d. My x = %d\n", getpid(), pid, x); } printf("Common part by PID %d, x = %d\n", getpid(), x); // 부모와 자식 모두 각자의 x 값을 가짐 return 0; }
위 예제(Example #1)에서
x
변수는fork()
호출 시점에 자식에게 복사된다. 이후 부모와 자식은 각자의x
를 수정하므로, 서로 다른x
값을 가지게 된다. 이는 각 프로세스가 독립적인 메모리 공간을 가짐을 보여준다.
2.2. fork()
심층 예제 분석
-
Fork Example #2 (두 번의
fork
): - 최초 프로세스 P0가
L0
출력. - P0가
fork()
: 자식 P1 생성. (P0, P1) - P0와 P1 모두 각자
L1
출력. - P0가
fork()
: 자식 P2 생성. (P0, P1, P2) - P1가
fork()
: 자식 P3 생성. (P0, P1, P2, P3) - 총 4개의 프로세스(P0, P1, P2, P3)가 각각
Bye
를 출력한다. (출력 순서는 스케줄링에 따라 달라질 수 있다.)
-
Fork Example #4 (부모만
fork
반복): - P0:
L0
출력.fork()
(자식 P1 생성). P0는if
참, P1은if
거짓. - P0:
L1
출력.fork()
(자식 P2 생성). P0는if
참, P2는if
거짓. - P0:
L2
출력.fork()
(자식 P3 생성). - P0, P1, P2, P3 모두 각자
Bye
를 출력한다. 총 4개의Bye
가 출력된다.
2.3. 생의 마감: exit()
시스템 콜 🏁
프로세스는 자신의 작업을 마치거나 오류가 발생했을 때 exit()
시스템 콜을 호출하여 스스로 종료한다.
void exit(int status);
2.4. fork()
후 파일 공유 방식: 테이블 관점 📖
이전 I/O 포스팅에서 다룬 커널의 3가지 테이블(Descriptor table, Open file table, v-node table) 관점에서 fork()
를 살펴보자.
Before fork() call |
- Descriptor Table: 각 프로세스마다 고유하다.
fork()
시 자식은 부모의 파일 디스크립터 테이블을 복사한다. - Open File Table: 시스템 전역적이다. 부모와 자식의 파일 디스크립터 테이블 내 동일한 인덱스(fd)는
fork()
후 동일한 Open file table 항목을 가리키게 된다. - v-node Table: 시스템 전역적이다. Open file table 항목이 v-node를 가리킨다.
결과적으로, fork()
전에 부모가 열어둔 파일 디스크립터들은 자식에게도 그대로 복사되며, 부모와 자식은 동일한 열린 파일에 대한 정보(파일 위치, 접근 모드 등)를 공유하게 된다. 해당 Open file table 항목의 참조 카운트(refcnt)는 1 증가한다. 이로 인해 부모나 자식 중 하나가 lseek()
로 파일 위치를 변경하거나 read()
/write()
를 수행하면, 다른 프로세스에게도 그 변경 사항(파일 오프셋)이 영향을 미친다.
3. 좀비 프로세스 (Zombie Process)와 부모의 책임 🧟♂️
-
좀비 프로세스란?: 자식 프로세스가
exit()
를 호출하여 종료했지만, 부모 프로세스가 아직wait()
또는waitpid()
시스템 콜을 호출하여 자식의 종료 상태를 수집(reap)하지 않은 상태의 프로세스를 말한다. (이전글의 시그널 참고)Zombies -
특징:
- 실행 코드는 없지만, 프로세스 테이블에는 최소한의 정보(PID, 종료 상태 등)가 남아있다.
ps
명령어 출력에서<defunct>
상태로 표시된다.- 시스템 자원을 거의 소모하지 않지만, 많은 수의 좀비 프로세스가 쌓이면 PID 고갈 등의 문제를 일으킬 수 있다.
-
수집의 의무: 부모 프로세스는 종료된 자식 프로세스를
wait()
또는waitpid()
로 반드시 수집해야 한다. -
고아 프로세스와
init
: 만약 부모 프로세스가 자식보다 먼저 종료되면, 해당 자식(고아 프로세스)은 시스템의 첫 번째 프로세스인init
프로세스(PID 1)의 자식으로 입양된다.init
프로세스는 주기적으로 자신의 좀비 자식들을 수집하는 역할을 한다.
4. 프로세스 동기화: wait()
와 waitpid()
🤝
부모 프로세스가 자식 프로세스의 실행을 제어하고 종료 상태를 안전하게 얻기 위해 사용되는 시스템 콜이다.
-
pid_t wait(int *child_status);
- 호출한 부모 프로세스를 블록(block)시키고, 임의의 자식 프로세스 중 하나가 종료될 때까지 기다린다.
- 종료된 자식의 PID를 반환한다. 만약 자식이 없다면 즉시 -1을 반환한다.
child_status
포인터가NULL
이 아니면, 자식의 종료 상태 정보가 이 포인터를 통해 전달된다.WIFEXITED(status)
: 자식이 정상 종료(exit()
)했는지 확인.WEXITSTATUS(status)
: 정상 종료 시 자식의 종료 코드 반환.WIFSIGNALED(status)
: 자식이 시그널에 의해 종료되었는지 확인.WTERMSIG(status)
: 시그널에 의해 종료 시 해당 시그널 번호 반환.
-
pid_t waitpid(pid_t pid, int *status, int options);
wait()
보다 더 유연한 기다림을 제공한다.pid
인자:pid > 0
: 특정 PID를 가진 자식을 기다린다.pid == -1
:wait()
와 동일. 임의의 자식을 기다린다.pid == 0
: 호출한 프로세스와 동일한 프로세스 그룹에 속한 자식을 기다린다.pid < -1
:-pid
값과 동일한 프로세스 그룹 ID를 가진 자식을 기다린다.
options
인자:0
: 블로킹 모드. 자식이 종료될 때까지 기다린다.WNOHANG
: 논블로킹 모드. 종료된 자식이 없으면 즉시 0을 반환한다.WUNTRACED
: 중단(stopped)된 자식에 대해서도 반환한다.
- Fork Example #11: N개의 자식을 생성 후,
waitpid(pid[i], &child_status, 0)
를 호출하여 특정 자식(pid[i]
)을 역순으로 기다려 수집한다.fork Example #11
5. 새로운 프로그램 실행: execve()
시스템 콜 🚀
fork()
가 현재 프로세스를 복제한다면, execve()
는 현재 프로세스의 메모리 공간(코드, 데이터, 스택)을 완전히 새로운 프로그램으로 교체한다.
execve |
int execve(const char *filename, char *const argv[], char *const envp[]);
filename
: 실행할 프로그램의 경로명.argv
: 새로운 프로그램에게 전달할 명령행 인자(argument)들의 배열. 관례적으로argv[0]
은 프로그램 이름이며, 배열의 마지막은NULL
포인터여야 한다.envp
: 새로운 프로그램에게 전달할 환경 변수(environment)들의 배열 ("KEY=VALUE"
형태). 배열의 마지막은NULL
포인터여야 한다. (getenv
,putenv
함수로 환경 변수를 관리할 수 있다).- 성공 시 반환하지 않는다: 현재 프로세스는 새로운 프로그램으로 완전히 대체되기 때문이다.
- 오류 시 -1을 반환하고
errno
를 설정한다. (예: 파일이 없거나 실행 권한이 없을 때)
일반적으로 쉘은 사용자의 명령을 실행할 때 fork()
로 자식 프로세스를 생성한 후, 자식 프로세스에서 execve()
를 호출하여 해당 명령(프로그램)을 실행하는 패턴을 사용한다. 부모(쉘)는 waitpid()
로 자식(명령)의 종료를 기다린다.
6. 스레드(Thread): 프로세스 내의 경량 실행 단위 🧵
스레드는 프로세스 내에서 실행되는 여러 개의 독립적인 실행 흐름을 의미한다. "Lightweight Process (LWP)"라고도 불린다.
- 프로세스의 전통적 관점: 하나의 프로세스는 하나의 제어 흐름(실행 경로)을 가진다.
Traditional View of a Process - 스레드를 포함한 관점: 프로세스는 프로세스 문맥(Process Context)과 하나 이상의 스레드 문맥(Thread Context)으로 구성된다.
- 프로세스 문맥: 코드, 데이터, 힙 영역, 파일 디스크립터 등 프로세스 전체에 공유되는 자원들.
- 스레드 문맥: 각 스레드가 독립적으로 가지는 요소들.
- 스레드 ID (TID)
- 스택 (Stack): 지역 변수, 함수 호출 정보 저장.
- 스택 포인터 (SP)
- 프로그램 카운터 (PC)
- 레지스터 값들
- 스케줄링 우선순위 등
- 자원 공유: 한 프로세스 내의 모든 스레드는 해당 프로세스의 코드, 데이터, 힙 영역, 그리고 열린 파일과 같은 OS 자원을 공유한다.
- 독립적 실행: 각 스레드는 자신만의 스택과 레지스터를 가지므로, 논리적으로 독립적인 제어 흐름을 가진다.
- 실행 방식:
- 단일 코어: 여러 스레드가 시분할 방식으로 컨텍스트 스위칭을 통해 동시 실행되는 것처럼 보인다.
- 멀티 코어: 여러 스레드가 실제로 여러 코어에 할당되어 병렬 실행될 수 있다.
6.1. 스레드 vs. 프로세스
특징 | 스레드 (Threads) | 프로세스 (Processes) |
자원 공유 | 코드, 데이터, 힙, 파일 등 대부분의 자원 공유 | 각자 독립적인 주소 공간 및 자원 (명시적 IPC 필요) |
생성 비용 | 상대적으로 저렴 (커널이 스레드 문맥만 생성) | 상대적으로 비쌈 (전체 프로세스 문맥 복제) |
문맥 전환 비용 | 상대적으로 저렴 (주소 공간 전환 불필요) | 상대적으로 비쌈 (주소 공간 및 캐시 등 전환) |
독립성 | 낮음 (한 스레드의 오류가 다른 스레드/프로세스에 영향 가능) | 높음 (한 프로세스의 오류가 다른 프로세스에 영향 적음) |
통신 | 공유 메모리를 통해 직접 통신 용이 | IPC (Pipes, Sockets, Shared Memory 등) 메커니즘 필요 |
Threads vs Process |
6.2. Posix 스레드 (Pthreads) 인터페이스
Pthreads는 IEEE POSIX 표준(POSIX.1c)에 정의된 스레드 프로그래밍을 위한 C/C++ 언어의 표준 API이다.
-
주요 함수:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
: 새로운 스레드를 생성한다.start_routine
이 스레드가 실행할 함수이다.int pthread_join(pthread_t thread, void **retval);
: 특정 스레드(thread
)가 종료될 때까지 기다리고, 해당 스레드의 반환 값을retval
로 받는다. (프로세스의waitpid
와 유사)pthread_t pthread_self(void);
: 호출한 스레드 자신의 ID를 반환한다.int pthread_cancel(pthread_t thread);
: 특정 스레드의 실행을 취소 요청한다.void pthread_exit(void *retval);
: 호출한 스레드 자신을 종료시킨다. (exit()
는 프로세스 전체를 종료시킨다는 점과 다르다).pthread_mutex_init()
,pthread_mutex_lock()
,pthread_mutex_unlock()
: 스레드 동기화를 위한 뮤텍스 관련 함수 (아래에서 자세히 다룸).
-
컴파일: Pthreads API를 사용하는 프로그램은 컴파일 시
-lpthread
(또는-pthread
) 옵션을 주어 Pthreads 라이브러리와 링크해야 한다.C// Pthreads "hello, world" 예제 #include <stdio.h> #include <pthread.h> #include <stdlib.h> void *thread_routine(void *vargp) { printf("Hello, world from new thread!\n"); return NULL; } int main() { pthread_t tid; printf("Main thread: Creating a new thread\n"); pthread_create(&tid, NULL, thread_routine, NULL); // 새 스레드 생성 pthread_join(tid, NULL); // 새 스레드가 종료될 때까지 기다림 printf("Main thread: New thread finished. Exiting.\n"); exit(0); // 프로세스 종료 }
Result of Pthreads Example |
Execution of Pthreads Example |
6.3. 스레드 기반 설계의 장단점
- 장점 (Pros):
- 병렬성 활용 용이: 멀티코어 환경에서 작업을 분산하여 성능 향상.
- 자원 공유 용이: 스레드 간 데이터 공유가 프로세스 간 공유보다 훨씬 쉽고 빠르다 (별도 IPC 불필요).
- 오버헤드 감소: 프로세스 생성/문맥 전환보다 스레드 생성/문맥 전환 비용이 훨씬 저렴하다.
- 응답성 향상: GUI 프로그램에서 시간이 오래 걸리는 작업을 별도 스레드로 처리하여 UI 응답성 유지.
- 단점 (Cons):
- 동기화 문제: 공유 자원에 대한 동시 접근 시 예기치 않은 결과(Race Condition 등) 발생 가능. 복잡한 동기화 메커니즘 필요.
- 복잡한 디버깅: 여러 스레드가 동시에 실행되므로 버그 재현 및 추적이 어렵다.
- 안정성 문제: 한 스레드의 오류(예: 잘못된 포인터 접근)가 같은 프로세스 내 다른 스레드나 프로세스 전체에 영향을 미쳐 다운될 수 있다.
7. 스레드 동기화 문제와 해결책: 상호 배제의 미학 ⚔️🛡️
여러 스레드가 공유 자원(Shared Resource)에 동시에 접근하려 할 때 발생하는 문제를 동기화 문제(Synchronization Problem)라고 한다. 대표적인 예가 경쟁 상태(Race Condition)이다.
7.1. badcnt.c
: 부적절한 동기화의 비극 💣
badcnt.c
예제는 전역 변수 cnt
를 두 개의 스레드가 각각 niters
번씩 증가시켜, 최종적으로 cnt
가 2 * niters
가 되기를 기대하는 프로그램이다.
badcnt.c |
// badcnt.c의 핵심 부분 (스레드 루틴)
volatile long cnt = 0; // 공유 변수
int niters;
void *thread(void *vargp) {
int i;
for (i = 0; i < niters; i++) {
cnt++; // 문제의 지점!
}
return NULL;
}
하지만 실제 실행 결과는 기대와 다르게 cnt
값이 2 * niters
보다 작게 나오는 경우가 빈번하다 (예: BOOM! cnt=13051
vs OK cnt=20000
). 이는 cnt++
연산이 원자적(atomic)이지 않기 때문이다. 어셈블리 수준에서 cnt++
는 대략 다음과 같은 3단계로 나뉜다.
- 로드 (Load): 메모리의
cnt
값을 CPU 레지스터(예:%eax
)로 읽어온다. (movl cnt(%rip), %eax
) - 갱신 (Update): 레지스터(
%eax
) 값을 1 증가시킨다. (incl %eax
) - 저장 (Store): 레지스터(
%eax
)의 새로운 값을 다시 메모리의cnt
위치에 쓴다. (movl %eax, cnt(%rip)
)
이 로드-갱신-저장 (Load-Update-Store) 시퀀스 중간에 다른 스레드가 끼어들어 cnt
값을 변경하면 문제가 발생한다. 이렇게 여러 스레드가 동시에 접근하면 안 되는 코드 영역을 임계 영역(Critical Section)이라고 한다. cnt++
부분이 바로 임계 영역이다.
Assembly Code for Counter Code |
7.2. 경쟁 상태 시나리오 분석
-
정상 (OK) 시나리오 (
cnt
가 0에서 시작, 두 스레드가 각각 한 번씩cnt++
):- Thread 1: Load (cnt=0, %eax1=0) -> Update (%eax1=1) -> Store (cnt=1)
- Thread 2: Load (cnt=1, %eax2=1) -> Update (%eax2=2) -> Store (cnt=2)
- 최종
cnt = 2
. (정상)
-
경쟁 상태 (Oops!) 시나리오:
- Thread 1: Load (cnt=0, %eax1=0)
- Thread 1: Update (%eax1=1)
- (문맥 전환 발생! Thread 1은 아직 Store 못함)
- Thread 2: Load (cnt=0, %eax2=0) (Thread 1이 Store 전이라 cnt는 여전히 0)
- Thread 2: Update (%eax2=1)
- Thread 2: Store (cnt=1)
- (문맥 전환 발생!)
- Thread 1: Store (cnt=1) (%eax1의 값 1을 덮어씀)
- 최종
cnt = 1
. (오류! 두 번 증가했지만 결과는 1)
7.3. 상호 배제 (Mutual Exclusion) 강제하기
경쟁 상태를 방지하려면 임계 영역에는 한 번에 하나의 스레드만 접근하도록 상호 배제(Mutual Exclusion)를 강제해야 한다. 이를 위한 대표적인 동기화 메커니즘이 뮤텍스(Mutex, MUTual EXclusion object)이다.
Mutex |
- 뮤텍스의 동작 원리:
- 뮤텍스는 잠금(Lock) 또는 해제(Unlock) 두 가지 상태를 가진다.
- 어떤 스레드가 임계 영역에 진입하기 전, 해당 뮤텍스를 잠가야(
pthread_mutex_lock
) 한다. - 만약 다른 스레드가 이미 뮤텍스를 잠근 상태라면, 잠금을 시도하는 스레드는 뮤텍스가 해제될 때까지 대기(블록)한다.
- 임계 영역에서의 작업을 마친 스레드는 반드시 뮤텍스를 해제(
pthread_mutex_unlock
)하여, 대기 중인 다른 스레드가 임계 영역에 진입할 수 있도록 해야 한다.
7.4. 뮤텍스를 사용한 동기화: goodcnt.c
✅
goodcnt.c
예제는 badcnt.c
의 문제를 뮤텍스를 사용하여 해결한다.
// goodcnt.c의 핵심 부분
#include <pthread.h>
volatile long cnt = 0;
int niters;
pthread_mutex_t mutex; // 뮤텍스 변수 선언
void *thread(void *vargp) {
int i;
for (i = 0; i < niters; i++) {
pthread_mutex_lock(&mutex); // 임계 영역 진입 전 잠금
cnt++; // 임계 영역
pthread_mutex_unlock(&mutex); // 임계 영역 탈출 후 해제
}
return NULL;
}
int main(int argc, char **argv) {
// ... (niters 설정, 스레드 생성 등) ...
pthread_mutex_init(&mutex, NULL); // 뮤텍스 초기화
// ... (스레드 생성 및 join) ...
// 결과 확인
if (cnt != (long)niters * 2)
printf("BOOM! cnt=%ld\n", cnt);
else
printf("OK cnt=%ld\n", cnt);
pthread_mutex_destroy(&mutex); // 뮤텍스 해제 (메인 함수 종료 전)
exit(0);
}
goodcnt.c
는 main
함수에서 pthread_mutex_init()
으로 뮤텍스를 초기화하고, 각 스레드 루틴에서 cnt++
임계 영역을 pthread_mutex_lock()
과 pthread_mutex_unlock()
으로 감싼다. 이렇게 하면 한 번에 하나의 스레드만 cnt
를 수정할 수 있으므로, 항상 정확한 결과(OK cnt=20000
)가 나온다. 다만, 뮤텍스 잠금 및 해제 연산 자체에도 오버헤드가 있으므로, 동기화가 없는 badcnt.c
보다 실행 속도는 느릴 수 있다.
8. 요약 및 마무리
이번 포스팅을 통해 시스템 프로그래밍의 양대 산맥인 프로세스와 스레드에 대해 로우레벨 관점에서 깊이 있게 탐구해 보았다.
-
프로세스: 운영체제가 관리하는 독립적인 실행 단위로, 각자 고유한 메모리 공간과 자원을 가진다.
fork()
로 생성되고,execve()
로 새로운 프로그램을 덮어쓰며,exit()
로 종료된다. 부모-자식 관계에서wait()
/waitpid()
를 통한 동기화와 좀비 프로세스 관리는 매우 중요하다. 문맥 전환은 동시성의 핵심이지만 오버헤드를 동반한다. -
스레드: 프로세스 내에서 자원을 공유하며 실행되는 경량 실행 단위이다. Pthreads API를 통해 생성 및 관리되며, 프로세스보다 생성 및 문맥 전환 비용이 저렴하여 동시성 및 병렬성 구현에 효과적이다. 하지만 공유 자원 접근 시 발생하는 경쟁 상태를 막기 위해 뮤텍스와 같은 동기화 메커니즘을 통한 상호 배제 적용이 필수적이다.
로우레벨에서의 이러한 동작 원리를 이해하는 것은, 효율적이고 안정적인 시스템 소프트웨어를 설계하고 개발하는 데 있어 강력한 무기가 될 것이다. 버그를 잡고, 성능을 최적화하며, 복잡한 동시성 문제를 해결하는 데 이 지식들이 든든한 기반이 되기를 바란다. 이로써 길고도 흥미로웠던 시스템 프로그래밍 수업을 모두 마친다.