[운영체제 실습] thread 이용해보기
실습 명세
1. Implement a simple counter using threads.
2. Calculate the time difference between a version using a single process and a version using multiple threads. (consider the time difference based on the number of threads.)
3. Determine how the results differ when using locks versus not using locks with threads.
4. Advanced: Implement a producer-consumer pattern using condition variables.
POSIX thread (pthread) 함수
1. pthread API
(1) int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void *(start_routine)(void *), void *arg);
- (역할) 새로운 thread 생성
- (param) thread : 새로운 thread의 ID (out)
- (param) attr: thread attribute configuration (기본 속성 사용 시, NULL)
- (param) start_routine: thread가 해야 하는 일
- (param) arg: arguments pass to start_routine
- (return) success=0, error=error_num
(2) int pthread_join(pthread_t thread, void** ret_val);
- (역할) 자식 thread가 종료될 때까지 기다리는 함수 (mater thread는 하나의 worker thread 만들고 끝날 때까지 기다렸다가 다음 일을 한다.)
- (param) thread_t: terminate 될 target thread의 ID
- (param) ret_val: terminated thread의 return value (out)
- (return) success=0
(3) void pthread_exit(void* ret_val);
- (역할) terminates the thread (thread를 시작하는 함수의 return call은 암묵적으로 pthread_exit을 호출)
- (param) ret_val: terminated thread의 return value.
2. MUTEX (critical section을 사용하기 위해 보호해 주는 역할)
(1) int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
- (역할) initialize a mutex object
- (param) mutex: mutex가 초기화될 pointer (in/out)
- (param) attr: mutex attributes (default=NULL)
- (return) succes=0, error=error_num
(2) int pthread_mutex_lock(pthread_mutex_t *mutex);
- (역할) lock a mutex
- (param) mutex: lock을 걸 mutex pointer
- (return) success=0, error=error_num
(3) int pthread_mutex_unlock(pthread_mutex_t *mutex);
- (역할) unlock a mutex
- (param) mutex: unlock할 mutex pointer
- (return) success=0, error=error_num
3. condition variable (특정 조건을 만족하기를 기다리는 변수)
(1) int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
- (역할) condition variable 초기화 for inter-thread signaling (thread 간 signaling)
- (param) cond: 초기화될 condition variable의 pointer (in/out)
- (param) attr: condition variable attributes
- (return) success=0, error=error_num
(2) int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
- (역할) condition variable이 signal 될 때까지 기다림
- (param) cond: wait하고 있는 condition variable의 pointer (in/out)
- (param) mutex: condition을 protect 하고 있는 mutex의 pointer (in/out)
→ 호출 시 mutex를 자동으로 unlock하고, 조건이 충족되어 signal을 받으면 mutex를 다시 lock (deadlock 방지) - (return) success=0, error=error_num
(3) int pthread_cond_signal(pthread_cond_t *cond);
- (역할) condition variable에 기다리고 있는 thread 하나에 signaling (wake up at least one thread waiting on the condition)
- (param) cond: signal 할 conditon variable의 pointer
- (return) success=0, error=error_num
(4) int pthread_cond_broadcast(pthread_cond_t *cond);
- (역할) 해당 condtion variable에서 기다리는 모든 thread를 signal
- (param) cond: broadcas 할 conditon variable의 pointer
- (return) success=0, error=error_num
(5) 주의할 점
- condition variable도 공유되는 변수이므로 mutually exclusive해야 한다.
- 반드시 if문이 아닌 while문으로 조건을 확인해야 한다
(ex) if문 조건을 확인할 때는 wait 조건을 충족하지 않았는데, 중간에 interrupt가 발생되어 wait 조건을 충족하게 됨
=> wait을 해야 하지만 하지 않는 상황 발생
3. pthread를 linux 환경에서 실행하기
아래 명령어를 통해 리눅스에서 pthread를 사용한 파일을 컴파일할 수 있다. window 환경에서는 pthread.h를 사용할 수 없다.
gcc -o counter counter.c -lpthread
Implementaion
1. simple counter 구현 (lock이 없는 버전)
#include <pthread.h>
#include <stdio.h>
#define NUM_THREADS 4
#define NUM_INCREMENTS 1000000
int counter = 0;
void* increment(void* arg) {
for (int i = 0; i < NUM_INCREMENTS; i++) {
counter++;
}
return NULL;
}
int main() {
pthread_t threads[NUM_THREADS];
// NUM_THREADS 개의 thread 생성
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, increment, NULL);
}
// 모두 실행할 때까지 기다림
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
// Expected counter보다 actual value가 과소하게 출력됨
printf("Expected counter: %d\n", NUM_THREADS * NUM_INCREMENTS);
printf("Actual value: %d\n", counter);
}
결과는 아래와 같다. Race Condition의 발생으로 counter의 기댓값보다 실제값이 더 작은 것을 확인할 수 있다.
$ ./counter_no_lock
Expected counter: 4000000
Actual value: 1775492
2. single process를 사용할 때와 thread를 사용할 때의 시간 차이
아래 코드로 NUM_THREADS와 NUM_LOOP만 수정해줘 가면서 시간 차이를 테스트해 봤다. race condition 없이 순수 실행 시간만 비교하기 위해 counter로 구현하는 대신 그냥 loop만 돌렸다.
#include <pthread.h>
#include <stdio.h>
#include <sys/time.h>
#include <unistd.h>
#define NUM_THREADS 3
#define NUM_LOOP 100000000
long get_elapsed_usec(struct timeval start, struct timeval end) {
return (end.tv_sec - start.tv_sec) * 1000000L + (end.tv_usec - start.tv_usec);
}
void* loop(void* arg) {
for (int i = 0; i < NUM_LOOP; i++);
return NULL;
}
long with_thread() {
pthread_t threads[NUM_THREADS];
struct timeval start, end;
gettimeofday(&start, NULL);
// NUM_THREADS 개의 thread 생성
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, loop, NULL);
}
// 모두 실행할 때까지 기다림
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
gettimeofday(&end, NULL);
return get_elapsed_usec(start, end);
}
long without_thread() {
struct timeval start, end;
gettimeofday(&start, NULL);
for (int i = 0; i < NUM_THREADS; i++) {
loop(NULL);
}
gettimeofday(&end, NULL);
return get_elapsed_usec(start, end);
}
int main() {
printf("duration using thread: %8ld\n", with_thread());
printf("duration without thread: %8ld\n", without_thread());
}
첫번째로, NUM_THREAD=2, NUM_LOOP=1,000 일 때의 실행 결과이다.
$ ./measure_time
duration using thread: 474
duration without thread: 5
보다시피 thread를 사용했을 때 오히려 더 많은 시간이 걸리는 것을 알 수 있다. 보통 thread를 사용하면 더 효율적으로, 더 짧은 시간으로 실행할 수 있을 것이라는 게 배웠던 내용인데, 오히려 thread로 사용하는 것이 더 오래 걸린다.
다양한 원인이 있을 수 있지만, 설정한 thread 수가 2개로 아주 적고, 반복 횟수가 작아, 한 thread 안에서의 작업이 그렇게 많지 않을 것이다. 그래서 오히려 thread로 인해 줄일 수 있는 시간보다 context switching으로 인해 늘어나는 시간이 더 커져서 발생하는 현상이라고 볼 수 있다. (multi thread를 사용한다는 것은 커널이 thread를 계속 바꿔가면서 써야 한다는 것이기 때문에 context switching 비용이 발생한다.)
따라서 다음은 우선 NUM_LOOP=100,000,000으로 충분한 수로 늘려서 다시 실행해 봤다.
$ ./measure_time
duration using thread: 279984
duration without thread: 517649
이제 thread를 사용하는 편의 시간이 더 짧아졌다. 즉, contex switching 비용보다 thread로 절약할 수 있는 시간이 더 커졌다는 의미이다.
마지막으로 여기서 NUM_THREAD=4로 thread의 개수만 늘려보았다.
$ ./measure_time
duration using thread: 362409
duration without thread: 1031092
thread를 사용한 것과 사용하지 않은 시간 간의 더 명확한 차이가 발생하는 것을 볼 수 있다.
결론적으로 하려는 일이 충분히 오랜 시간이 걸리는 경우 thread를 사용하는 것이 더 유리하다.
3. mutex lock을 적용한 counter 구현
처음에 구현한 simple counter에서 increament 함수에서 crtical region에 해당하는 counter 증가 부분 앞뒤로 lock을 걸어주고 풀어주기만 하면 된다. (main 함수는 같아서 생략했다.)
#include <pthread.h>
#include <stdio.h>
#define NUM_THREADS 4
#define NUM_INCREMENTS 1000000
int counter = 0;
pthread_mutex_t lock;
void* increment(void* arg) {
for (int i = 0; i < NUM_INCREMENTS; i++) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
// 생략
결과는 아래와 같다. lock을 걸지 않았을 때는 기댓값과 실제값이 달랐지만, lock을 걸고 나니 기댓값과 실제값이 똑같은 것을 알 수 있다. 즉, Race Condition으로 인한 문제가 해결되었다.
$ ./counter_with_lock
Expected counter: 4000000
Actual value: 4000000
4. producer-consumer pattern 구현
producer-consumer pattern을 아래와 같이 구현해 봤다. printf와 main 함수는 모두 디버깅 로그를 기록하기 위함이다. (결과 확인용)
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define BUFFER_SIZE 5
#define LOOP 10
int count = 0;
int in = 0, out = 0;
int buffer[BUFFER_SIZE];
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// buffer 비어있을 때 wait 시킬 condtion variable
pthread_cond_t empty = PTHREAD_COND_INITIALIZER;
// buffer 가득 찼을 때 wait 시킬 condition variable
pthread_cond_t full = PTHREAD_COND_INITIALIZER;
void put(int val) {
buffer[in] = val;
in = (in + 1) % BUFFER_SIZE;
count++;
}
int get() {
int val = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--;
return val;
}
void* producer(void* arg) {
for (int i = 0; i < LOOP; i++) {
pthread_mutex_lock(&lock);
while (count >= BUFFER_SIZE) {
printf("producer: buffer full...\n");
pthread_cond_wait(&full, &lock);
// consumer가 element 하나 소비하고 signal 해줄 때까지 wait
}
put(rand() % 100);
printf("producer: in=%d, count=%d\n", in, count);
pthread_cond_signal(&empty);
// consumer에 새로운 element 넣었다고 signal
pthread_mutex_unlock(&lock);
}
}
void* consumer(void* arg) {
for (int i = 0; i < LOOP; i++) {
pthread_mutex_lock(&lock);
while (count <= 0) {
printf("consumer: buffer empty...\n");
pthread_cond_wait(&empty, &lock);
// producer가 새로운 element 넣고 signal 해줄 때까지 wait
}
printf("consumer: get value: %d\n", get());
pthread_cond_signal(&full);
// producer에 element 하나 소비했다고 signal
pthread_mutex_unlock(&lock);
}
}
int main() {
pthread_t prod, cons;
pthread_create(&prod, NULL, producer, NULL);
pthread_create(&cons, NULL, consumer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
}
결과는 아래와 같이 나왔다. 결과가 너무 길어 뒷부분은 생략했다. 디버깅 메시지가 출력되는 것을 보면, 처음에 producer가 지정된 buffer size(10)만큼 모두 채워서 full이 출력되고 더 이상 produer가 동작하지 못한다. 그리고 consumer가 동작하고, full condition variable에 signal을 주고 나서 다시 produer가 동작 가능해진 것도 볼 수 있다. consumer의 동작도 마찬가지이다.
$ ./prod_cons
producer: in=1, count=1
producer: in=2, count=2
producer: in=3, count=3
producer: in=4, count=4
producer: in=0, count=5
producer: buffer full...
consumer: get value: 83
consumer: get value: 86
consumer: get value: 77
consumer: get value: 15
consumer: get value: 93
consumer: buffer empty...
producer: in=1, count=1
producer: in=2, count=2
producer: in=3, count=3
producer: in=4, count=4
producer: in=0, count=5
consumer: get value: 35
consumer: get value: 86
consumer: get value: 92
consumer: get value: 49
consumer: get value: 21