IT 그리고 정보보안/Knowledge base

리눅스 환경의 스레드(Threads)

plummmm 2021. 4. 15. 20:19
반응형

우리가 흔히 다중 작업을 떠올려본다면 fork를 이용하여 여러개의 프로세스를 생성하여 각각 작업을 하는 것을 떠올릴 것이다.

근데 프로세스를 새로 생성한다는 것은 생각보다 무거운 작업이다. 그래서 스레드라는 것이 나온다.

 

스레드는 하나의 프로세스가 여러개의 작업을 동시에 할 수 있도록 해주는 것이다.

이렇게 하나의 프로세스에 여러개의 스레드를 둔다면, 프로세스가 동시에 여러가지 작업을 할 수 있게 된다.

(시간을 쪼개어 CPU 점유를 분할하는 것임)

 

아파치 웹서버를 예를 들어보자.

 

위 처럼 웹서버 같은 데서는 스레드를 이용하여 여러 명의 사용자와 세션을 맺어 다중 처리 서버를 구현할 수 있다.

 

스레드는 다른 영역은 공유하지만, 스택영역은 별개로 쓴다.

​그리하여 fork 보다 훨씬 효율적이다. 

 

 

스레드간에는 텍스트, 데이터, 힙 영역, 파일 디스크립터를 공유하고

스레드 ID, 레지스터 값, 스택 영역, 스케쥴링 우선순위, 신호 마스크 등으로 구분할 수 있다.

 

 

그림을 보면 하나의 프로세스에 여러개의 스레드들이 할당되어 있음을 알 수 있다.

프로세스와 스레드는 각각 PCB(Process Control Block)  TCB(Thread Control Block) 을 갖고 있다.

 

스레드를 사용하면 다음과 같은 장점이 있다.

1. 정보를 쉽게 공유할 수 있다. (별다른 처리 없이 메모리 공간과 파일 디스크립터들에 접근 가능)

2. 성능이 향상된다. (각각 다른 작업을 수행하는 스레드를 생성하므로.. 이건 작업 스레드)

3. 좀더 상호작용이 용이하다. (대화식 프로그램 같은 경우 I/O 스레드를 분리하면 더 빠름.. 이건 UI 스레드)

 

그럼 스레드는 어떻게 쓰는 건가? 이제 차차 알아갈 것이다.

일단 UNIX 환경에서 스레드는 POSIX에 정의된 Pthread("POSIX threads")라고 불리는 스레드 인터페이스를 사용한다. 

Pthread의 모든 함수들은 pthread_ 로 시작한다.

 

컴파일 할 땐 gcc -lpthread xxx.c 이런 식으로 한다.

 

스레드 식별

자 스레드의 개념을 익혔으니 이제 스레드 식별하는 함수들에 대해 알아보자.

프로세스에 프로세스 ID(PID)가 있듯이, 스레드에도 스레드 ID 즉, TID가 존재한다.

 

보통은이 TID가 정수값인데.. OS환경마다 좀 다르다.

(FreeBSD 에서는 주소값으로, Linux에선 Unsigned Long int 값=정수)

 

여튼 우리는 unix/linux 환경에서 스레드를 공부하는 것이니 TID는 정수값이겠지?

이 TID를 식별하는 함수들이 있다.

 

현재 스레드의 tid를 알아보는 pthread_self()

두개의 tid를 비교하는 pthread_equal() 두가지 이다.

 

pthread_self는 인자도 없다. 그냥 이렇게 호출하면

현재 스레드의 tid를 반환한다.

 

pthread_equal 함수는 비교할 스레드 2개의 pthread_t 자료형을 인자로 넣는다.

 

스레드 생성

스레드를 생성하는 작업에 대해 알아보자. pthread_create() 라는 함수로 생성한다.

프로세스 생성하는 것과 비교하면 fork에 해당한다고 볼 수 있다.

 

인자가 총 4가지 정도 들어간다. 복잡해 보이지만 뭐 없다.

pthread_t *tidp : 생성되는 스레드의 tid를 저장하는 구조체 포인터

- const pthread_attr_t *attr : 스레드 환경을 나타내는 인자 그냥 default = NULL 이다.

- void *(*start_rtn)(void) : 스레드가 시작할 때 실행되는 시작 함수를 등록하는 인자이다.

void *arg : 등록하는 시작 함수의 인자를 등록하는 인자다.

 

백문이 불여일코드.. 예제코드를 봅시다.

새로운 스레드를 하나 생성하여 스레드 tid를 찍어내는 예제이다.

printtids 함수안에 tid를 두번 찍은 이유는 정수일 수도 있고 포인터 일수도 있기 때문.

 

스레드 시작시 실행 함수를 등록할 때보면 함수포인터로 등록해야 한다.

실행을 하면 위와같이 나온다.

Linux를 보면 pid가 다른데, pid가 항상 같지는 않다.

Linux가 pthread_create 함수를 시스템콜 중 하나인 clone으로 구현하기 때문임.

clone은 자식 프로세스를 생성한다.

 

스레드 종료

스레드를 만들어 봤으니 스레드를 종료하는 함수도 알아봐야지.

스레드가 exit()나 _exit(), _Exit() 를 호출하면 프로세스 전체가 종료되어 버린다. 이러면 안되겠지?

 

스레드가 프로세스 전체를 종료하지 않고 지만 종료되는 경우는 3가지가 있다.

1. 스레드 시작 루틴이 정상적으로 종료되어 스레드 종료 코드를 반환하는 경우

2. 동일 프로세스의 다른 스레드가 취소 요청을 하였을 경우

3. pthread_exit() 함수가 호출된 경우

 

일단 3번 pthread_exit() 함수를 보자.

 

인자로 void 포인터를 받는다.

*rval_ptr : 스레드 종료 코드를 받는 인자이다. 이 코드를 곧 설명할 pthread_join함수로 넘긴다.

자세한 사용은 아래에 예제코드 설명할 때 하겠다.

 

위에서 말했듯이 pthread_exit 함수의 인자 종료코드를 pthread_join 함수로 넘긴다고 했다.

pthread_join 함수는 프로세스 제어 함수로 치면 wait() 랑 같은 함수이다.

 

pthread_t thread : 종료 코드를 회수할 스레드. 이녀석이 종료되지 않으면 pthread_join을 호출한 스레드는 계속 블록된다.

void **rval_ptr : ptrhead_exit가 반환하는 종료코드를 저장하는 인자. NULL로 두면 반환값 회수 안한다.

 

예제코드를 보면 이해가 빨리 될 것 같은 느낌이 든다.

 

 


스레드 시작 루틴 thr_fn1, thr_fn2 를 두고 

fn1은 정상 종료, fn2는 pthread_exit 함수로 인한 종료 두가지로 

각각 1,2 반환 코드를 pthread_join 함수로 넘기는 프로그램 코드이다. 

 

주의할 것은 반환 코드는 void 포인터형 이라는거.

pthread_join(tid1, &tret) 를 한번 보면

tid1 스레드가 종료될 때 까지 블록된다. &tret은 반환코드를 저장한다.

 

실행해보면

 

 

이어 계속해서 pthread_cancel 함수를 알아보자.

스레드 종료(1)에서 스레드가 프로세스 전체를 종료하지 않고 지만 종료되는 경우는 3가지가 있다고 했다.

거기서 2번, 동일 프로세스상의 다른 스레드가 나를 종료시키는 함수가 바로 pthread_cancel 이다. 

 

 

그냥 쉽게 말해 취소요청을 하는 것이다. 프로세스의 abort() 함수와 유사하게 비정상 종료를 유도한다.

wait 함수처럼 스레드가 종료될 때까지 블락되어 기다려주고 이딴거 없다. 그냥 취소요청하는 함수이다.

 

기본적으로 pthread_t tid 에 등록된 스레드가 PTHREAD_CANCELED를 인자로하여 

ptrhead_exit함수를 호출하는 것과 같은 동작을 한다.

 

그리고 atexit() 함수처럼 스레드가 종료되기 전에 특정 함수를 호출하고 종료하는 것도 가능하다.

pthread_cleanup_push, pthread_cleanup_pop 함수들로 컨트롤한다.

 

 

pthread_cleanup_push 함수의 인자부터 알아보자. 종료 직전에 수행될 함수를 등록하는 함수이다.

void(*rtn)(void *) : 종료 직전에 실행될 함수 즉, cleanup_handler를 등록하는 인자.

void *arg : cleanup_handler 의 인자.

 

cleanup_handler를 여러개 두는 것도 가능한데, 핸들러들이 모두 같은 스택에 쌓인다.

스택은 뭐다? 후입선출. 등록한 순서 역순으로 핸들러가 수행된다. 

그럼 이렇게 pthread_cleanup_push 함수로 등록하니, 삭제하는 함수도 있겠지..

그게 바로 pthread_cleanup_pop 함수이다.

int execute : 인자로 0이 아닌 값을 받으면 핸들러 수행후 삭제, 0이면 그냥 삭제

 

push함수와 pop함수는 반드시 짝을 이루어주어야 한다. 아니면 컴파일이 안될 수도 있다.

 

흠.. 그럼 예제코드를 볼 차례인데, 그전에 하나더 알고 있어야 하는 것

이렇게 등록한 cleanup_handler 들은 도대체 언제 수행되는 것일까?

1. 스레드 안에서 pthread_exit 함수가 수행될 때

2. cancel 신호를 받았을 때

3. 스레드 안에서 0이 아닌 인자로 pthread_cleanup_pop이 수행될 때.

 

예제코드 보자. 글로만 적을라니까 갑갑하다.

 


코드를 보고 어떻게 실행될지 예상해보자.
스레드 2개를 생성하고 각 스레드들이 cleanup_handler를 2개씩 등록하는 것이다.

fn1의 cleanup_handler는 실행되지 않을 것이다. (return 으로 정상종료 되므로)

fn2의 cleanup_handler는 등록한 역순으로 출력되겠지. (스택)

마지막으로 pthread_함수들이 프로세스 관련 함수들 어떤어떤 것들과 유사한지 매칭시켜보자.

 

스레드 동기화

이번에 알아볼 내용은 스레드 동기화에 대한 내용이다. 이게 왜 필요한 것인가??

여러개의 스레드를 사용시, 하나의 스레드에서 변수를 수정하면 

다른 스레드들이 그 변수를 읽거나 수정할 때 일관성에 문제가 생긴다.

스레드 동기화란, 공유 자원에 접근하는 멀티 스레드 환경에서 일관성을 유지하기 위해 필요한 것이다.

쉽게 말해서 내가 작업하고 있는데 남이 손대면 문제가 생긴다는 것이다.

내가 작업하기 전의 내용을 들고가 다른쪽에서 들고가 작업하면 결과가 겹칠 수도 있고..

 

 

만약 이 작업들이 원자적인 연산으로 이루어진다면 경쟁 조건이 생길 이유가 없다.

하지만 이런 경우가 잘 없기 때문에.. 일관적으로 작업이 이루어지지 않는다.

원자적 연산(Atomic operation)

어느 한순간에 딱 하나만 연산하는 것을 원자적 연산이라 한다.도중에 어떠한 방해도 받지 않고 딱 하나의 연산만 하는 것.

 

그래서 내가 만약 write 작업 중이라면 다른 read작업을 block 시켜야 하는 그런 문제가 발생한다는 말임.

이 동기화 문제를 해결하기 위해 두가지를 알아볼 것이다. mutex(Mutual Exclusion) 과 Conditional Variable 이다.

 

Mutex (Mutual exclusion)

한글 번역으로 상호 배제라고도 부른다. 개인적으로 이런 IT 용어들은 번역 안하고 그대로 표기했음 좋겠다. 뭔가 말이 이상함

​ 

스레드 동기화 문제를 해결하기 위한 방법중 일환이다. 가장 일반적인 방법이다. 뮤텍스는 쉽게 말해 좌물쇠 개념이다.

 

스레드가 공유자원에 접근하기 전에 LOCK을 걸고 작업이 끝나면 해제하는 역할을 하는 것.

뮤텍스가 걸려 있으면 다른 스레드는 공유 자원에 접근을 못한다.

 

Pthreads 인터페이스에서 뮤텍스의 변수 형식은 pthread_mutex_t 인데,

이녀석을 사용하려면 먼저 뮤텍스를 초기화 시키는 pthread_mutex_init 이라는 함수가 호출되어야 한다.

해당 메모리를 해제할 때는 반드시!!! pthread_mutex_destroy도 호출해야 한다.

*mutex : 이건 뭐 말안해도 뮤텍스이다.

*attr : 뮤텍스의 설정값들인데 일단 NULL로 둔다.

 

이제 초기화를 했으니 뮤텍스를 LOCK 시켜야 할 것이다.

뮤텍스를 lock시켜서 lock시킨 동안 스레드가 블록되는 pthread_mutex_lock 함수와

lock이 걸려있더라도 블록되지 않는 pthread_mutex_trylock, 그리고

뮤텍스를 해제하는 pthread_mutex_unlock 함수 들이 있다.

 

예제보자.

f_count 만큼 자원 공유가 가능하도록 선언하였고

f_lock은 뮤텍스 상태확인 변수이다.

 

pthread_mutex_init 함수를 이용하여 뮤텍스 초기화 하는 부분이다. 

 

 

foo_hold 부분은 레퍼런스 카운트를 올리는 부분 

foo_rele는 참조를 해제하는 부분이다.

 

이 소스는 count lock 이라는 예제로, 스레드 n개 까지 자원을 공유할 수 있도록 해주는 프로그램이다.

공유 자원을 사용하려는 스레드가 추가될 때 먼저 foo_hold로 들어가서 카운트를 증가시키고

사용이 끝나면 foo_rele에서 해제를 한다. 마지막 참조가 해제되면 foo_rele에서 메모리를 해제한다.

 

 

조건 변수 (Condition variables)

자 이번에는 뮤텍스 말고 스레드 동기화 문제를 해결할 수 있는 다른 방법 중 하나인 조건 변수 (condition variable) 을 알아보자.

뮤텍스(mutex)에서는 일정한 조건으로 lock을 걸고 unlock 하는지, 언제 락을 걸었는지 등은 알 방법이 없다.

하지만 조건 변수라는 개념을 도입하면 조건에 따른 lock과 unlock이 가능하다.

 

조건변수 자체는 뮤텍스로 lock을 걸어둔다.

위 그림처럼 조건 변수를 두고 조건에 만족하지 않는 task들은 큐에 들어가게 된다.

조건에 만족하면 작업을 수행하는 것이지.

 

뮤텍스와 마찬가지로 조건변수를 사용할 때도 초기화 작업이 필요하다.

 

뮤텍스랑 쓰는법 똑같다. 감이 안오면 나중에 예제코드를 보면 알 수 있음.

그리고 조건변수에서는 지정된 시간동안 조건이 참이 되지 않을 때 오류를 반환하는 함수도 있다.

 

pthread_cond_wait 함수는 조건이 참이 될때까지 기다리는 함수이다.

pthread_cond_timedwait 함수는 pthread_cond_wait와 똑같긴 한데 시간을 설정하여 지정한 시간이 지나면

큐에서 나오도록 한다.

wait상태에 들어가면 뮤텍스가 해제되고 다시 깨어나면 뮤텍스가 잠긴다.

 

그리고 조건이 만족되었음을 알려주는 함수들이 있다. 그만자고 일어나라 이러는 애들

 

pthread_cond_signal 함수는 특정 스레드 하나를 깨우고

pthread_cond_broadcast 함수는 전체를 깨운다.

 

예제코드를 봅시다.

 

위 소스는 mutex와 조건변수를 같이 사용하여 스레드 동기화 제어한 코드이다.

반응형