제목 : 멀티 스레드의 특성과 C 에서의 사용법 - 1편
이 글을 작성하는 이유는?
사실 컴퓨터를 배우는 입장에서 보면, 멀티 스레드라는 개념을 맞닥들일 일이 많지는 않을 것이다.
배우는 과정에서도
- 알고리즘
- 자료구조
- 네트워크
- 인프라
- 등등..
이를 중점으로 배우게 된다.
프로세스와 스레드는 운영체제에서 효율적으로 관리하고,
사용자 스레드 단에서도 프레임워크가 굳이 프로세스를 복잡하게 작성하지 않게 도와주며,
심지어는 굳이 스레드를 생성하지 않고, Docker or K8s(쿠버네티스) 와 같은
"Infra Ochestration"(오케스트레이션) 과 같은 도구들로 단숨에 동일한 프로그램을 복제 할 수 있다.
사실상 그 상위인 가벼운 운영체제를 복제한다고 볼 수 있다.
물론, 이러한 주제들 또한 단순 프로그래밍 수준이 아니라,
각 프로그램 언어가 가진 고유의 문단을 이해하고, 이를 이용하여 단축하는 수준에 이르러서야
이들을 그래도 어느정도 이해한다고 말할 수 있지 않을까 싶다.
특히나, 일반적인 프로그래밍에서 멀티 스레드를 사용 할 일은 거의 없다.
워낙에 하드웨어 성능이 좋으며, 현재까지 만들어진 컴퓨터 알고리즘 라이브러리들은
빠른 성능에 날개를 달아준 수준인데, 연산을 상황에 따라 100 배, 1000배, 등등 더한 수준도 최적화한다.
그러나, 이는 어디까지나 하드웨어와 소프트웨어가 받쳐주는 수준이지,
실제 프로덕션 상황에서는 단일 스레드 프로그램으로 인한 병목 현상을 맞이 할 수 있다.
프로세스와 스레드는 동의어 수준으로 사용된다. (물론 프로세스는 스레드의 상위 개념)
프로세스와 스레드에 대한 고찰을 자세히 적은 글이 있는데,
이것을 읽고 오면 앞으로 말할 프로세스와 스레드에 대해 자세히 알게 될 것이라고 생각한다.
Tomcat 과 Spring 은 유저의 요청에 따라 스레드가 생성되고,
완료 시 스레드를 Pool (Thread Pool) 에 반환하는 특성을 가진다.
이를 자동으로 성립시켜 주는데,
덕분에 Tomcat, Spring 기반의 프로젝트를 실행하는 개발자는
굳이 각 연결에 대한 비동기적인 처리를 걱정 할 필요 없이,
"한 사람에 대한 연산 및 데이터 요청" 에 집중할 수 있다.
마치 단일 스레드 로직을 작성하는 것 같지만, 결국 프레임워크가 스레드를 pool 에서 꺼내어
이를 동시에 처리하는 것이 매우 인상적이라고 생각한다.
그러나, 하나의 프로세스에서 처리되는 과정에서,(그래픽, GUI, 연산 등등)
우리는 프레임워크에 할당된 기능만으로는 한계점이 분명히 존재한다는 것을 인지해야 한다.
그 분들이 만들어 놓은 깊고도 깊은 기능을 이용하고도 제작 못하는 논리구조가 있을 가능성을 고려하면.
거의 95 % 는 심연의 Function or Class 로 해결할 수 있을 테지만,
단순히 특정 기능을 외워서 사용하기보단, 이러한 기능의 동작 원리를 이해해야
다양한 언어 및 프레임워크 환경에서도 나의 생산성이 올라갈 수 있을 것이라고 생각했다.
현재는 AI 시대의 개발자의 역할에 대해서 깊게 고민했으며,
나의 역할에 대한 고찰과 그 과정 중에 있다.
현재는 C 언어를 통해, 다양한 언어들이 어떻게 편의적으로 표현되었는지,
어떤 특징을 따왔는지 배우며, 직접 구현하고 있다.
예시로, 나는 이런 글을 작성한다.
C 언어를 통해서 스레드를 구현하면,
다양한 언어들이 각자 생성하는 프로세스, 스레드의 방식을 이해할 수 있을 것이다.
pthread 란 무엇인가?
POSIX 규칙 및 API 를 따르고 적용하고 있는 운영체제,
예를 들어 윈도우, 리눅스, 리눅스 배포판, macOS 와 같은 운영체제에서 사용 가능한 라이브러리이다.
이정도면 Cross Platform Lib 로 취급해도 무방하다는 생각이 든다.
"pthread" 는, POSIX Threads 의 약자이다.
pthread 라이브러리는 여러 운영체제에서 스레드를 다루기 위한 다양한 API 를 제공한다.
또한, 스레드에 원하는 조건이나 객체(구조체?) 속성을 지정하여 원하는 대로 스레드를 사용할 수도 있다.
속성에 대한 예를 들면,
- 스레드 및 스레드 속성 오브젝트
- 뮤텍스 및 뮤텍스 속성 오브젝트
- 조건 변수 및 조건 속성 오브젝트
- 읽기 및 쓰기 잠금
여기서 처음 보는 것은, Mutex 라는 용어이다.
살짝 미리 보았는데, 예를 들어서 "터미널에 출력" 하는 I/O 객체 혹은 구조체 데이터가 공유되고 있을 때,
특정 스레드가 터미널 I/O 를 시작하면서, Mutex 라는 오브젝트를 이용하여 "잠금" 을 한다.
"잠금" 시, 입출력 객체를 공유하고 있는 스레드 사이에서, 현재 "잠금" 이후 사용중인 스레드를 제외하고,
어떠한 스레드도 현재 입출력 객체를 사용할 수 없다.
그리고, I/O 가 끝나면, 해당 스레드는 Mutex 를 이용하여 "해제" 를 한다.
원하는 디테일한 상황이 아니라면, 단순히 NULL 을 속성에 집어넣어 Default 속성으로 사용할 수도 있다.
pthread 스레드 초기 작성 이전, 알아두면 좋을 함수와 구조체
나 또한 스레드를 저수준으로 다루어 보는 것은 처음이라서,
IBM 기술문서, 그리고 네이버 기술 블로그를 혼합해서 pthread 를 이해하는 중이다.
여기서 기술되며, 내가 앞으로 말하게 될 단어와 용어에 대해서 정리를 해야겠다고 생각한다.
- 루틴 :
routine: 스레드를 생성할 때, 실행 할 함수(함수 포인터)
pthread_t: 스레드 ID --> 정수이며, 생성된 스레드 ID 를 담을 타입 --> 스레드 핸들러pthread_attr_t: 스레드 속성 Object or 구조체 포인터 --> 스레드 성질
pthread_attr_init(&pthread_attr_r): 스레드 속성 객체를 기본값으로 초기화한다.pthread_attr_destroy(&pthread_attr_r): 스레드 속성 객체를 소멸시킨다.
--> init 에서 동적 할당한 저장영역 해제
int pthread_create(&pthread_t, &pthread_attr_t, init_routine, args)
: 이 함수를 이용하여 스레드를 생성 후init_routine을 실행한다.
: 첨부된 루틴이 끝나면, 자동으로 끝난다.
int pthread_join(&pthread_t, void** status)
: 이 메서드를 호출한 스레드는, 인자로 들어간 스레드가 종료 될 때 까지 로직을 중단 및 차단한다.
: 그리고 인자로 들어간 대상 스레드의 종료 상태는status매개변수로 주입한다.
: 성공 시0을 반환하며, 오류 시 해당 오류 번호를 리턴한다.
int pthread_detach(&pthread_t)
: 이 함수는 바로 위의join과 대치되는 로직으로 사용된다.
: 인자로 들어간 스레드는 로직이 종료 될 경우, 위의 함수를 사용하지 않고, 그 즉시 모든 자원을 free 한다.
:pthread_join의 경우, 메인 스레드가 서브 스레드가 종료 될 때 까지 기다리는 느낌이라면, 이 함수는 기다리지 않는다는 느낌으로 해석했다.
void pthread_exit(void* status)
: 이 함수는 프로세스의 전체 종료를 의미하는exit함수와는 다르게, 스레드를 종료한다.
:이 스레드를 호출하면, 호출 스레드가 종료된다.
: 여기서 메인 함수에서 만약에 "호출 스레드 종료" 인pthread_exit을 선언하게 된다면,
메인이 선언한 스레드들은 자신들의 역할이 끝날 때 까지 끝나지 않는다는 것이,
exit과의 차이점이다.
pthread 선언의 흐름
아무래도 내가 pthread.h 에 대해서 자세히 아는 것은 아니라서,
전체적인 스레드 관리의 흐름과 더불어 되새김질 하기 위해서 그래프를 만든다.
flowchart TB
subgraph variable ["스레드를 위한 변수 공간 생성"]
pthread_t("pthread_t <br/> : 스레드 핸들러 및 스레드 ID")
pthread_attr_t("pthread_attr_t <br/> : 해당 스레드의 속성 혹은 성질")
end
subgraph initialize ["스레드 속성 초기화 -> 디폴트"]
pthread_attr_init("pthread_attr_init <br/> : 스레드 속성 디폴트 혹은 사용자 지정")
end
subgraph create ["스레드 생성 With 함수 포인터"]
pthread_create("pthread_create <br/> : 인자는, <br/> - pthread_t <br/> - pthread_attr_t <br/> - function ptr <br/> - arg(인자)")
end
subgraph join ["메인은 서브 스레드를 기다린다."]
pthread_join("pthread_join <br/> : 이 함수를 호출한 스레드는 인자로 넣은 스레드가 종료 될 때 까지 로직을 중단하고 기다린다.")
end
subgraph destroy ["안쓰는 메모리 속성 해제"]
pthread_attr_destroy("pthread_attr_destroy <br/> : 더 이상 스레드 생성 하지 않거나, 참조 불필요 시 속성 해제")
end
subgraph exit ["스레드 종료"]
pthread_exit("pthread_exit <br/> : 이 함수(루틴) 을 선언한 스레드는 정상 종료 반환을 하게 된다. <br/> 이는 프로세스 전체 종료가 아니며, 선언한 해당 스레드만을 의미한다. <br/> 만약 메인에서 호출한다면, 메인에서 호출된 스레드들은 종료되지 않으며, 자기 할 일을 하고 끝낸다.")
end
variable --> initialize
initialize --> create
create --> join
join --> destroy
destroy --> exit
이는 프로세스의 기능 중에서 가장 중요하고, 단순한 것들을 위주로 설명했다.
더 자세한 이해를 원한다면, 맨 밑의 참조 사이트에서 IBM 과 JOINC 사이트를 참조하면 좋다!
코드 작성 후 동작 확인
프로세스와 스레드 이론을 이전에 공부하면서 느꼈던 건데,
잘못하면 내 컴퓨터에 Fork Bomb 을 만들어 버릴 수 있어서 조심히 다루어겠다는 생각이 든다.
Fork Bomb 이란, 스레드가 프로세스를 영원히 fork 해버리는 상황.
순식간에 2^xx 번 복제실행을 할 수 있다는 생각이 든다.
#include<pthread.h>
#include<stdio.h>
void *test_routine(void* data) {
int i = 0;
while(i <= 5) {
fputc(i++ + '0', stdout); fputc('\n', stdout);
}
return data;
}
int main(void) {
pthread_t thread_t;
pthread_attr_t thread_attr_t;
pthread_attr_init(&thread_attr_t);
pthread_create(&thread_t, &thread_attr_t, test_routine, NULL);
int status;
pthread_join(thread_t, (void*)&status);
// or pthread_exit(&status);
return 0;
}
만약, pthread_join 이나, pthread_exit 을 선언하지 않는다면,
main 함수에서 먼저 return 0 해버리면서, 프로세스 내 모든 스레드가 종료되면서
숫자들을 출력 할 수 없다.
$ <만든 파일.c> -Wall -Wextra -pthread -o <원하는 파일 이름>
-Wall: 컴파일 단계에서 알려줄 수 있는 모든 경고를 출력.-Wextra: 확장된 영역의 경고를 추가한다.-pthread:pthread.h를 사용한다면, 무조건 선언해야 하는 옵션이다.
컴파일 중 링크 단계에서 pthread 를 인식하게 해주므로 매우 중요.-o: 어떤 파일 이름으로 출력물을 내뱉을 건지.
원한다면, 이런식으로 축소해도 된다.
$ <만든 파일.c> -pthread -o <원하는 이름>
그리고, 파일을 간단하게 실행 해 보자.
$ ./<컴파일 된 출력물 이름>
# 나의 경우. --> 나는 현재 문서에 따른 분류를 위해 '번호' 를 매기고 있음.
$ ./6-exam-1
0
1
2
3
4
5
레퍼런스 '&' 는 왜 사용하는 걸까?
나는 백준 문제를 다양한 프로그래밍 언어로 풀었는데,
현재는 그 중 C99 기준 C 를 사용하여 문제를 풀고 있다.
극도로 제한된 라이브러리들을 '직접 구현' 하며, 문법 표현력을 강제로 늘리고 있다.
그 과정에서, 나는 레퍼런스를 사용하지 않고, 모든 구조체나 배열의 값을 * 포인터를 사용하여 표현했는데,
프로세스를 다루는 과정에서는 int 타입을 void* 로 넣기 위해, &(레퍼런스) 를 사용하는 등,
메인 콜백 메모리에 잠시 남을 데이터나 원시값을 & 를 이용하여 리턴값을 가져오는 것이 이해가 되지 않았다.
정확한 레퍼런스와 포인터의 의미
& : 해당 타입의 '주소값' 을 생성하여 넘김.(혹은 이미 있는 주소값)
* : 이 값에 지정된 포인터의 메모리로 간다.
결과적으로
예제에서 int 타입을 인자로 전해 줄 때, & 를 사용 한 이유는,
int 인자를 받는 함수가 내부적으로 int* 를 결국 참조하기 때문이다.
즉, 굳이 예제에서 복잡하게 동적 메모리, malloc 등등을 이용하여 참조하게 만들고,
이 메모리를 해제하는 과정을 배제하고, 정확히 스레드 로직에 집중하기 위해 이러한 예제를 든 것이다.
내가 원한다면, (레퍼런스를 없애고 싶다면,) 전부 * 포인터 타입으로 만들어서 동작하게 만들 수 있다.
그러나, 예제에서는 선택과 집중을 위해 레퍼런스& 를 사용하여 간단히 표현했다.
&thread_t가 된 이유는, 이미thread_t가 포인터 타입으로 선언되었기 때문이다.&thread_attr_t가 된 이유도, 위의 이유와 동일하다.
함수 앞에 '*' 는 왜 붙은 걸까?
C 언어를 사용하여 문제를 풀면서 느낀 건데,
자료구조를 구조체로 만들어서 사용하면서, 함수의 주입이 생각보다 편하다는 것이었다.
함수는 포인터 메모리로서 저장될 수 있다.
또한, 함수는 특정한 인자들과 반환값을 가지는 '함수 포인터' 를 생성 할 수도 있다.
나는, 이러한 함수를 만들 수 있다.
int test(int n, int m);
int main(void) {
int (*function)(int, int) = *test;
int result = function(1, 2);
return 0;
}
int test(int n, int m) {
return n + m;
}
void* 는 무엇일까?
void 는 어떠한 것도 없는 '공허' 를 의미한다.
반환 값에 void 가 붙는다면, 어떠한 것도 반환하지 않음을 의미한다.
그러나, void* 처럼, 포인터가 붙으면 의미가 180 도 변한다.
void* 는, 해당 값이 어떠한 값도 될 수 있음을 의미한다. (단순한 값, 혹은 포인터)
여기서 포인터 * 가 하나씩 더 붙을 때 마다, 이 변수에서 참조할 수 있는 차원이 점점 높아진다.
보통, void*, void** 를 많이 이용한다.
JavaScript, TypeScript 를 배운 사람에게 이를 설명하자면, 약간 any 의 느낌이 있다고 보면 된다.
단, 전달된 값을 '정확히' Parsing 해야 사용할 수 있다. (주소 or 타입 or 구조체 or 배열 등등..)
스레드 간의 동기화
네트워크 통신, 그리고 멀티 스레딩의 꽃이라고 부를 수 있는 주제가 아닐까 생각한다.
나는 멀티 스레드를 처음 구현한 언어가 JavaScript 였다.
여기서 상호작용을 구현하면서 공유 데이터 사용과 변화를 적용하는 것이 매우 헷갈렸었는데,
이번에 IBM 문서를 통해 이해하게 될 것 같다.
스레드 라이브러리는, 이러한 "동기화 매커니즘" 을 제공한다.
- "뮤텍스" (
Mutex) - 조건 변수
- 읽기-쓰기 잠금
- "조인" (
pthread_join)
뮤텍스란? (Mutex)
IBM 문서의 설명을 그대로 가져오면,
"뮤텍스는 상호 배타적 잠금입니다. 단 하나의 스레드만이 잠글 수 있습니다."
라고 표현한다.
누군가 집에서 화장실을 이용하고 있다면, 다른 누군가는 나올 때 까지 기다려야 할 것이다.
사람 == 스레드, 화장실 == 뮤텍스 속성 오브젝트
이렇게 표현 할 수 있다.
그래서 뮤텍스는 왜 필요할까?
만약에 입출력에 관련된 멀티 스레드를 작성하고, 이를 공유한다고 가정 해 보자.
A 스레드와 B 스레드는 출력하고자 하는 내용이 다르다고 생각 해 보자.
A 스레드와 B 스레드가 각각 출력하고자 하는 긴 내용을 각자 거의 동시에 출력을 시작한다.
그렇다면, A 와 B 에서 내보낸 내용이 섞여서 나올 수 있다.
이러한 현상을 막고자, "뮤텍스" 를 사용한다.
뮤텍스 과정에서 사용되는 간단한 구조체와 함수들
뮤텍스 과정에서 사용될 수 있는 "상대적으로" 간략한 구조체와 함수들이라고 볼 수 있다.
내가 "상대적으로 간략" 하다고 말하는 이유가,
뮤텍스 구조체(객체)가 정말로 일종의 "Key" 처럼 이용되기 때문이다.
스레드, 혹은 프로세스에는 현재 상태를 나타내는 문자열들이 존재한다.
여기서, R, S, X, ... 등등 여러가지 상태가 있다.
준비중, 대기중, 실행중 등등을 ps 명령어로 볼 수 있는 것이다.
여기서 중요한 것은, 어떻게 프로세스나 스레드가 "대기 상태" 에 있을 수 있나 하는 것이다.
프로세스나 스레드가 "대기 상태" 로 진입하는 것은, pthread_join 루틴 호출 후 대기와 같은 상황도 있겠지만,
Mutex 객체를 공유하며, 특정 스레드가 Mutex 에 lock 을 걸어버린 경우,
해당 스레드가 unlock 을 할 때 까지 기다리는 상황도 "대기 상태" 라고 부를 수 있다. EX - S
처음 제시했던 함수들을 다시 복기하며, 새로운 구조체와 함수들도 익혀보자.
pthread_t: 스레드 ID --> 스레드 핸들러 (이전에도 사용)pthread_mutex_t: 뮤텍스 구조체 --> 이거 사용해서 스레드를 unlock 및 lock 한다.pthread_mutex_attr_t: 뮤텍스 사용 할 때 행동할 방침같은 것
pthread_mutex_init: 뮤텍스 객체(구조체) 를 디폴트 상태로 만듬pthread_mutex_destroy: 다 사용했으면 메모리를 해제하는 데 사용한다.(뮤텍스 객체를)
pthread_create: 스레드 생성 시 기본으로 사용됨. (이전에도 사용됨)
pthread_mutex_lock: 현재 뮤텍스 객체에 잠금을 가하면, 같은 뮤텍스에 접근하는 다른 스레드들이 대기한다.pthread_mutex_unlock: 보통 lock 을 한 스레드가unlock하여 기다리는 스레드가 뮤텍스에 접근할 수 있도록 한다.
코드 작성 후 동작을 확인 해 보자.
몇 가지 예시를 들 건데,
파일 스코프로 선언된 전역 뮤텍스 객체에 접근하는 예제와,
인자로 스레드 함수에 뮤텍스 객체가 전달되어 공유되는 예제이다.
상황 - 뮤텍스 없음
// 함수 인자로 뮤텍스 객체를 공유하는 예제
#include<stdio.h>
#include<pthread.h>
#include<unistd.h> // unix 에서 파생된 유틸리티들. 파일을 읽고 쓰거나, 기다리는 행위를 보조한다.
void *fn_1(void* data);
void *fn_2(void* data);
// 파일 전역 객체인 Mutex 선언
pthread_mutex_t mutex;
int main(void) {
pthread_t t1;
pthread_t t2;
pthread_attr_t attr;
pthread_attr_init(&attr);
int start = 1;
pthread_create(&t1, &attr, fn_1, &start);
sleep(1);
pthread_create(&t2, &attr, fn_2, &start);
// status 를 조회할 필요는 없어 NULL 로 넣음.
pthread_join(t1, NULL); pthread_join(t2, NULL);
return 0;
}
void *fn_1(void* data) {
int start = *(int*)data;
int idx = start;
while(idx < start + 4) {
printf("%d ", idx++);
sleep(2);
}
printf("\n");
pthread_exit(NULL);
return data;
}
void *fn_2(void* data) {
int start = *(int*)data;
int idx = start;
while(idx < start + 4) {
printf("%d ", idx++);
sleep(2);
}
printf("\n", stdout);
pthread_exit(NULL);
return data;
}
$ gcc <이 파일.c> -Wall -Wextra -pthread -o <이 파일 이름>
$ ./<이 파일 이름>
1 1 2 2 3 3 4 4
$
보면, sleep 을 사용하여 1초, 혹은 2 초를 기다리게 했는데,
이는 각 스레드 출력을 교차시켜 뮤텍스가 없는 상황을 연출하기 위함이다.
보다시피, 스레드가 대기하지 않고, 메인 스레드만이 sleep(1) 을 통해 1 초만 기다리도록
지시했기 때문에, fn_1 스레드와, fn_2 스레드가 "동시에" 실행 된 것을 볼 수 있다.
상황 - 전역 뮤텍스 객체에 접근
// 전역 스코프로 뮤텍스 객체에 접근하는 예제
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
void *fn_1(void* data);
void *fn_2(void* data);
// 파일 전역 객체인 Mutex 선언
pthread_mutex_t mutex;
int main(void) {
pthread_t t1;
pthread_t t2;
pthread_attr_t attr;
// 스레드 기본 속성 Default
pthread_attr_init(&attr);
// 뮤텍스 객체 기본 속성으로 설정 --> NULL
pthread_mutex_init(&mutex, NULL);
int start = 1;
pthread_create(&t1, &attr, fn_1, &start);
sleep(1);
pthread_create(&t2, &attr, fn_2, &start);
// status 를 조회할 필요는 없어 NULL 로 넣음.
pthread_join(t1, NULL); pthread_join(t2, NULL);
return 0;
}
void *fn_1(void* data) {
int start = *(int*)data;
// 뮤텍스 잠금
pthread_mutex_lock(&mutex);
int idx = start;
while(idx < start + 4) {
printf("%d ", idx++);
sleep(2);
}
printf("\n");
// 뮤텍스 잠금 해제
pthread_mutex_unlock(&mutex);
pthread_exit(NULL);
return data;
}
void *fn_2(void* data) {
int start = *(int*)data;
// 뮤텍스 잠금
pthread_mutex_lock(&mutex);
int idx = start;
while(idx < start + 4) {
printf("%d ", idx++);
sleep(2);
}
printf("\n", stdout);
// 뮤텍스 잠금 해제
pthread_mutex_unlock(&mutex);
pthread_exit(NULL);
return data;
}
$ gcc <이 파일.c> -Wall -Wextra -pthread -o <이 파일 이름>
$ ./<이 파일 이름>
1 2 3 4
1 2 3 4
$
이제 "파일 스코프"로 선언된, 전역 객체 pthread_mutex_t 를 이용하여,
스레드 잠금 및 해제를 수행한 것을 볼 수 있다.
상황 - 인자로 전달된 뮤텍스 객체에 접근
// 함수 인자로 뮤텍스 객체를 공유하는 예제
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<pthread.h>
// void* data 인자로 전달할 Element 구조체 선언.
typedef struct Element {
int start;
pthread_mutex_t* mutex; // 생성된 뮤텍스 객체의 "주소" 를 담기 위함
FILE* stream; // 출력 스트림
} Element;
void *fn_1(void* data);
void *fn_2(void* data);
int main(void) {
pthread_t thread_1; // --> fn_1 실행
pthread_t thread_2; // --> fn_2 실행
pthread_attr_t thread_attr;
pthread_mutex_t mutex; // 여기서 뮤텍스 객체가 자동으로 생성됨. --> 메모리가 있는 상태 (중요)
pthread_mutexattr_t mutex_attr;
// 속성들 초기화
pthread_attr_init(&thread_attr);
pthread_mutexattr_init(&mutex_attr);
// 디폴트 상태로 뮤텍스 객체 초기화
pthread_mutex_init(&mutex, &mutex_attr);
// 스레드에 전달할 데이터 구조체 메모리 선언
Element* elem = (Element*)malloc(sizeof(Element));
elem->mutex = &mutex; elem->start = 1; elem->stream = stdout;
// fn_1 스레드 시작
pthread_create(&thread_1, &thread_attr, fn_1, (void*)elem);
sleep(1);
// 1 초 후 fn_2 스레드 시작
pthread_create(&thread_2, &thread_attr, fn_2, (void*)elem);
// 모든 스레드가 종료 될 때 까지 기다리기
pthread_join(thread_1, NULL);
pthread_join(thread_2, NULL);
// 생성했던 뮤텍스, 스레드 속성 객체를 해제.
pthread_mutexattr_destroy(&mutex_attr);
pthread_attr_destroy(&thread_attr);
// 메인 로직 스레드 해제
pthread_exit(NULL);
return 0;
}
void *fn_1(void* data) {
// 인자로 전달된 데이터는 Element* 형태이다.
Element* elem = (Element*)data;
// 저장된 뮤텍스 객체의 "주소" 를 가져온다.
pthread_mutex_t* mutex = elem->mutex;
// 뮤텍스 잠금
pthread_mutex_lock(mutex);
int start = elem->start;
FILE* stream = elem->stream;
int idx = start;
while(idx < start + 3){
fputc(idx + '0', stream); fputc(' ', stream);
idx++;
sleep(2);
}
fputc('\n', stream);
elem->start = idx;
// 뮤텍스 해제
pthread_mutex_unlock(mutex);
// 스레드 해제
pthread_exit(NULL);
}
void *fn_2(void* data) {
Element* elem = (Element*)data;
pthread_mutex_t* mutex = elem->mutex;
pthread_mutex_lock(mutex);
int start = elem->start;
FILE* stream = elem->stream;
int idx = start;
while(idx < start + 3){
fputc(idx + '0', stream); fputc(' ', stream);
idx++;
sleep(2);
}
fputc('\n', stream);
pthread_mutex_unlock(mutex);
pthread_exit(NULL);
}
실행 결과 :
$ gcc <파일.c> .... -pthread -o <파일>
$ ./<파일>
1 2 3
4 5 6
위의 예제에는 큰 맹점이 존재한다.
위의 예제를 생각했던 대로 차례대로 실행시키기 위하여 생각보다 많은 수정이 필요했다.
공식 문서에서는 파일 전역 스코프로 선언되어 함수 내부에서 곧바로 참조하던데,
main 함수에서 뮤텍스를 선언하고, 이를 넘기고 나면, 자꾸 동기화가 풀리는 것이다.
이 현상이 도무지 이해가 되지 않았다.
알고 보니, 동적으로 생성한 뮤텍스가 아니라, "정적 스코프로 선언한 뮤텍스 객체" 인 것이다.
나도 이게 이해가 되질 않았다. 마치 일반 타입으로 선언되어,
새로운 스레드 함수에서 단순하게 pthread_mutex_t mutex = ... 로 할당을 할 수가 없는 것이다.
동기화가 깨지고, 다시 2 개의 스레드가 동시에 출력하고 있는 것을 뵬 수 있었다.
정답은, pthread_mutex_t mutex 를 선언하는 그 즉시, 새로운 뮤텍스 객체가 생성되는 것이었다.
내가 하는 행동은,
main 의 뮤텍스, fn_1 의 뮤텍스, fn_2 의 뮤텍스를 각각 생성하여 락인하는 것과 동일했다.
pthread_mutex_t 타입으로 main 함수에서 단순하게 선언했을 경우,
pthread_mutex_init 으로 디폴트 값을 채우게 되는데,
이 때 "정적 타입" 이 되버리기 때문에, "주소값" 을 넘겨주어야 한다.
그러니까, main 에 pthread_mutex_t 가 묶여있는 거고, (정적 타입으로 선언되어서)
이걸 다른 스레드에서 pthread_mutex_t 로 할당받을 수 없다.
pthread_mutex_t 가 main 에서 생성된 뮤텍스 객체의 내부를 "얕은 복사" 로 가져가기 때문에,
정확히 main 뮤텍스를 참조하는 것이 아니라, 새로운 스레드가 "새로운 뮤텍스" 를 생성하는 꼴이 된다.
따라서, 우리는 "만약에" 뮤텍스를 인자로 넘기고 싶다면,
처음 pthread_mutex_t 선언 후, init 하고,
&, 그리고 * 로 참조해야 한다.
참고로, pthread_mutex_t 정의를 직접 열어 보면,
이미 상위 선언에서 xxxxx* 로 포인터 선언이 되어 있는 것을 볼 수 있다.
그래서 더 헷갈릴 수도 있다.
Mutex 를 동적으로 내 마음대로 다루고 싶다면,
이는 역으로,
pthread_mutex_t* mutex = malloc(sizeof(pthread_mutex_t)); 가 가능하다.
물론, 단순 선언이 아니라,
동적 메모리 생성이기 때문에, free(mutex) 를 적절한 때에 해 줘야 하는 걸 잊어선 안된다.
중간 마무리 --> 다음 내용은 이어서 제작
요약
스레드를 만드는 다양한 라이브러리들이 존재하지만,
프로세스 단에 속한 중요한 요소이기 때문에, 이는 Standard 라이브러리로 제공된다.
즉, 직접적으로 스레드를 "생성" 하는 것이 아니라,
제공되는 저 수준의 라이브러리를 통해, "스레드 생성" 을 수행한다.
예를 들어 위의 코드를 보자면, 코드에서 단순히
- 타입
- 함수
를 import 해서 사용하지,
우리는 커널에 직접적으로 스레드를 요청하지 않았다.
이는 편의성과 보안을 위해서이다.
중요한 것은, 스레드의 동기화 부분이다.
다양한 스레드를 생성하여 단일 계산에서 벗어나, 시분할 시스템으로 효율있게 계산하는 것은 좋지만,
특정 프로젝트를 진행하거나, 프로그램을 실행 할 때,
우리는 특정 스레드가 특정 영역만을 담당하고, 해당 영역은 또 다른 스레드가 요청하는 데이터 일 수도 있다.
따라서, 이 과정은 스레드 간의 "의존성" 이 생기는 부분일 것이다.
이러한 의존성에 대한 부분을 해결해 주는 것이, 바로 "Mutex" 이다.
스레드 간의 동기화 부분에서, 뮤텍스 뿐만 아니라, Condition 을 사용하는 등, 방법이 많다.
참조 사이트 모음
IBM --> AIX 공식 문서
https://www.ibm.com/docs/ko/aix/7.2.0?topic=processes-pthread-implementation-files
위키피디아 --> pthread
https://ko.wikipedia.org/wiki/POSIX_%EC%8A%A4%EB%A0%88%EB%93%9C
네이버 기술 블로그 - "[C] pthread란? pthread예제"
https://m.blog.naver.com/whtie5500/221692793640
JOINC - "Pthread API 레퍼런스"
https://www.joinc.co.kr/w/Site/Thread/Beginning/PthreadApiReference
GeeksForGeesks - "Function Pointer in C"
'Computer Science' 카테고리의 다른 글
| Docker 는 어떻게 운용되며 실행할까? (Dockerfile + CLI) (1) | 2025.11.18 |
|---|