책정리/열혈 TCP,IP

18장 멀티쓰레드 기반의 서버구현

GONII 2015. 8. 5. 16:18
  • 쓰레드의 이론적 이해

    쓰레드의 등장배경

    멀티 프로세스 기반의 단점은 다음과 같다

    • 프로세스 생성이라는 부담스러운 작업과정을 거친다
    • 두 프로세스 사이에서의 데이터 교환을 위해서는 별도의 IPC 기법을 적용해야 한다.
    • 컨텍스트 스위칭(Context Switching)에 따른 부담은 프로세스 생성방식의 가장 큰 부담이다

    CPU가 하나인(CPU의 연산장치인 CORE) 시스템에서 둘 이상의 프로세스가 동시에 실행되려면 CPU의 할당시간을 매우 작은 크기로 쪼개서 서로 나누어 사용해야 한다. 그런데 CPU의 할당시간을 나누기 위해서는 '컨텍스트 스위칭'이라는 과정을 거쳐야 한다.

    프로그램의 실행을 위해서는 해당 프로세스의 정보가 메인 메모리에 올라와야 한다. 때문에 현재 실행중인 A프로세스의 뒤를 이어서 B프로세스를 실행시키려면 A프로세스 관련 데이터를 메인 메모리에서 그리고 B프로세스 관련 데이터를 메인 메모리로 이동시켜야 한다. 이것이 컨텍스트 스위칭이다. 그런데 이때 A프로세스 관련 데이터를 하드디스크로 이동하기 때문에 컨텍스트 스위칭에는 오랜 시간이 걸리고, 빨리 진행한다 하더라도 한계가 있다.

    결국 멀티프로세스의 특징을 유지하면서 단점을 어느 정도 극복하기 위해서 '쓰레드(Thread)'라는 것이 등장하는데, 이는 멀티프로세스의 여러 가지 단점을 최소화하기 위해서 설계된 일종의 경량화된 프로세스이다. 쓰레드는 프로세스와 비교해서 다음과 같은 장점이 있다

    • 쓰레드의 생성 및 컨텍스트 스위칭은 프로세스의 생성 및 컨텍스트 스위칭 보다 빠르다.
    • 쓰레드 사이에서의 데이터 교환은 특별한 기법이 필요치 않다

    쓰레드와 프로세스의 차이점

    프로세스의 메모리 구조는 전역변수가 할당되는 '데이터 영역',

    malloc 함수 등에 의해 동적 할당이 이뤄지는 '힙(haep) 영역',

    함수의 실행에 사용되는 '스택(stack)영역'으로 이뤄진다.

    프로세스들은 이를 완전히 별도로 유지된다. 때문에 프로세스 사이에서는 다음의 메모리 구조를 보인다.

    그런데 둘 이상의 실행흐름을 갖는 것이 목적이라면, 위 그림처럼 완전히 메모리 구조를 분리시킬 것이 아니라, 스택 영역만 분리시킴으로써 다음의 장점을 얻을 수 있다.

    • 컨텍스트 스위칭 시 데이터영역과 힙은 올리고 내릴 필요가 없다
    • 데이터 영역과 힙을 이용해서 데이터를 교환할 수 있다.

    그래서 등장한 것이 쓰레드이며, 모든 쓰레드는 별도의 실행흐름을 유지하기 위해서 스택 영역만 독립적으로 유지하기 때문에 다음의 메모리 구조를 보인다.

    위 그림에서 보이듯이 데이터 영역과 힙 영역을 공유하는 구조로 쓰레드는 설계되어 있다. 그리고 이를 위해서 쓰레드는 프로세스 내에서 생성 및 실행되는 구조로 완성되었다. 즉, 프로세스와 쓰레드는 다음과 같이 정의할 수 있다.

프로세스

운영체제 관점에서 별도의 실행흐름을 구성하는 단위

쓰레드

프로세스 관점에서 별도의 실행흐름을 구성하는 단위

프로세스가 하나의 운영체제 안에서 둘 이상의 실행흐름을 형성하기 위한 도구라면, 쓰레드는 하나의 프로세스 내에서 둘 이상의 실행흐름을 형성하기 위한 도구로 이해할 수 있다.

  • 쓰레드의 생성 및 실행

    POSIX란 Potable Operating System Interface for Computer Environment의 약자로써 UNIX 계열 운영체제간에 이식성을 높이기 위한 표준 API 규격을 뜻한다. 그리고 쓰레드의 생성방법은 POSIX에 정의된 표준을 근거로 한다. 때문에 리눅스 뿐만 아니라, 유닉스 계열의 운영체제에서도 대부분 적용 가능하다.

    쓰레드의 생성과 실행흐름의 구성

    쓰레드는 별도의 실행흐름을 갖기 때문에 쓰레드만의 main함수를 별도로 정의해야 한다. 그리고 이 함수를 시작으로 별도의 실행흐름을 형성해 줄 것을 운영체제에게 요청해야 하는데, 이를 목적으로 호출하는 함수는 다음과 같다

#include <pthread.h>

int pthread_create(pthread_t *restrict thread, const pthread_attr_t* restrict attr, void*(*start_routine)(void*), void* restrict arg);
// 성공 : 0
// 실패 : 0 이외의 값 반환

  • thread

    생성할 쓰레드의 ID 저장을 위한 변수의 주소 값 전달, 쓰레드는 프로세스와 마찬가지로 쓰레드의 구분을 위한 ID가 부여된다.

  • attr

    쓰레드에 부여할 특성 정보의 전달을 위한 매개변수, NULL 전달 시 기본적인 특성의 쓰레드 생성

  • start_routine

    쓰레드의 main함수 역할을 하는, 별도의 실행흐름으로 시작이 되는 함수의 주소값(함수 포인터) 전달

  • arg

    세 번째 인자로 통해 등록된 함수가 호출될 때 전달할 인자의 정보를 담고 있는 변수의 주소 값 전달

  • 예제 thread1.c

#include <stdio.h>

#include <pthread.h>

   

void* threadMain(void* arg);

   

int main(int argc, char* argv[])

{

pthread_t t_id;

int threadParam = 5;

   

if( pthread_create(&t_id, NULL, threadMain, (void*)&threadParam) != 0 )

{

puts("ptherad_create() error");

return -1;

}

   

sleep(10);

puts("end of main");

return 0;

}

   

void* threadMain(void* arg)

{

int i;

int cnt = *((int*)arg);

for( i = 0 ; i < cnt ; i++ )

{

sleep(1);

puts("running thread");

}

return NULL;

}

쓰레드의 실행을 관리한다는 것은 프로그램의 흐름을 예측한다는 뜻인데, 이는 사실상 불가능한 일이다. 그리고 잘못된 구현은 프로그램의 흐름을 방해하는 결과로 이어질 수 있다. 이러한 문제점 때문에 sleep 함수보다는 다음 함수를 이용해서 쓰레드의 실행흐름을 조절한다. 즉, 다음 함수를 사용하면 지금 이야기하고 있는 문제를 쉽고 효율적으로 해결할 수 있다.

#include <pthrad.h>

int pthread_join(pthread_t thread, void** status);
// 성공 : 0
// 실패 : 0 이외의 값 반환

  • thread

    이 매개변수에 전달되는 ID의 쓰레드가 종료될 때까지 함수는 반환하지 않는다

  • status

    쓰레드의 main 함수가 반환하는 값이 저장될 포인터 변수의 주소값을 전달한다

위 함수는 첫 번째 인자로 전달되는 ID의 쓰레드가 종료될 때까지 이 함수를 호출한 프로세스(또는 스레드)를 대기상태에 둔다. 뿐만 아니라 threadMain함수가 반환하는 값까지 얻을 수 있으니 그만큼 유용함 함수이다.

  • 예제 thread2.c

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <pthread.h>

   

void* threadMain(void* arg);

   

int main(int argc, char* argv[])

{

pthread_t t_id;

int threadParam = 5;

void* threadRet;

   

if( pthread_create(&t_id, NULL, threadMain, (void*)&threadParam) != 0 )

{

puts("pthread_create() error");

return -1;

}

   

if( pthread_join(t_id, &threadRet) != 0 )

{

puts("pthread_join() error");

return -1;

}

   

printf("thread return message:%s\n", (char*)threadRet);

free(threadRet);

return 0;

}

   

void* threadMain(void* arg)

{

int i;

int cnt = *((int*)arg);

char* msg = (char*)malloc(sizeof(char) * 50);

strcpy(msg, "Hello, I'm thread\n");

   

for( i = 0 ; i < cnt ; i++ )

{

sleep(1);

puts("running thread");

}

   

return (void*)msg;

}

임계영역 내에서 호출이 가능한 함수

함수 중에는 둘 이상의 쓰레드가 동시에 호출하면 문제를 일으키는 함수가 있다. 이는 함수 내에 '임계영역(Critical Section)'이라 불리는, 둘 이상의 쓰레드가 동시에 실행하면 문제를 일으키는 문장이 하나 이상 존재하는 함수이다. 이러한 임계영역의 문제와 관련해서 함수는 다음 두 가지 종류로 구분이 된다.

  • 쓰레드에 안전한 함수(Thread-safe function)
  • 쓰레드에 불안전한 함수(Thread-unsafe function)

여기서 쓰레드에 안전한 함수는 둘 이상의 쓰레드에 의해서 동시에 호출 및 실행되어도 문제를 일으키지 않는 함수를 뜻한다. 반대로 쓰레드에 불안전한 함수는 동시호출 시 문제가 발생할 수 있는 함수를 뜻한다.

대부분의 표준함수들은 쓰레드에 안전하다. 쓰레드에 불안전한 함수가 정의되어 있는 경우, 같은 기능을 갖는 쓰레드에 안전한 함수가 정의되어 있다.

일반적으로 쓰레드에 안전한 형태로 재 구현된 함수의 이름에는 _r이 붙는다.

헤더파일 선언 이전에 매크로 _REENTRANT를 정의하면 _r 함수의 호출로 자동화 할 수 있다.

워커(Worker) 쓰레드 모델

  • thread3.c

#include <stdio.h>

#include <pthread.h>

   

void* thread_summation(void* arg);

int sum = 0;

   

int main(int argc, char* argv[])

{

pthread_t id_t1, id_t2;

int range1[] = {1, 5};

int range2[] = {6, 10};

   

pthread_create(&id_t1, NULL, thread_summation, (void*)range1);

pthread_create(&id_t2, NULL, thread_summation, (void*)range2);

   

pthread_join(id_t1, NULL);

pthread_join(id_t2, NULL);

printf("result: %d\n", sum);

return 0;

}

   

void* thread_summation(void* arg)

{

int start = ((int*)arg)[0];

int end = ((int*)arg)[1];

   

while(start <= end )

{

sum += start;

satart++;

}

   

return NULL;

}

  • thread4.c

#include <stdio.h>

#include <unistd.h>

#include <stdlib.h>

#include <pthread.h>

#define NUM_THREAD 100

   

void* thread_inc(void* arg);

void* thread_dec(void* arg);

   

long long num = 0;

   

int main(int argc, char* argv[])

{

pthread_t thread_id[NUM_THREAD];

int i;

   

printf("sizeof long long:%d\n", sizeof(long long));

for( i = 0 ; i < NUM_THREAD ; i++ )

{

if( i % 2 )

pthread_create(&(thread_id[i], NULL, thread_inc, NULL);

else

pthread_create(&(thread_id[i], NULL, thread_dec, NULL);

}

   

for( i = 0 ; i < NUM_THREAD ; i++ )

pthread_join(thread_id[i], NULL);

   

printf("result : %11d\n", num);

return 0;

}

void* thread_inc(void* arg)

{

int i;

for( i = 0 ; i < 5000000; i++ )

num += 1;

   

return NULL;

}

void* thread_dec(void* arg)

{

int i;

for( i = 0 ; i < 5000000; i++ )

num -= 1;

   

return NULL;

}

  • 쓰레드의 문제점과 임계영역(Critical Section)

    하나의 변수에 둘 이상의 쓰레드가 동시에 접근하는 것이 문제

    예제 thread4.c는 "전역 변수 num에 둘 이상의 쓰레드가 함께(동시에) 접근하고 있다"라는 문제점이 있다.

    여기서 말하는 접근이란 주로 값의 변경을 뜻한다. 그런데 다양한 상황에서 문제가 발생할 수 있기 때문에 문제의 원인이 무엇인지 정확히 이해해야 한다. 그리고 예제에서는 접근의 대상이 전역변수였지만, 이는 전역변수였기 때문에 발생한 문제가 아니다. 어떠한 메모리 공간이라도 동시에 접근을 하면 문제가 발생할 수 있다.

       

    위 그림은 변수 num에 저장되어 있는 값을 증가시키려는 두 개의 쓰레드가 존재하는 상황을 묘사한 것이다.

    다음 그림은 thread1이 변수 num에 저장된 값을 참조해서 값을 1 증가시키는 것까지 완료한 상황을 보여준다. 단, 변수 num에는 아직 증가된 값을 저장하지 않았다.

    이제 100이라는 값을 변수 num에 저장해야 하는데 이 작업이 진행되기 전에 thread2로 실행의 순서가 넘어가 버렸다. 그리고 thread2는 증가연산을 완전히 완료해서 증가된 값을 변수 num에 저장했다고 가정하자.

    위 그림에서 보이듯이 변수 num에 저장된 값이 thread2에 의해 변경된 것이다. 이제 남은 일은 thread1이 증가 시킨 값을 num에 저장하는 것이다. 그리고 작업을 완료하면 num에는 100이 저장되게 된다.

    threa1과 thread2가 각각 1씩 증가시켰지만 엉뚱한 값이 저장될 수 있다. 이러한 문제를 막기 위해서 한 쓰레드가 num에 접근해서 연산을 완료할 때까지, 다른 쓰레드가 변수 num에 접근하지 못하도록 막아야 한다. 이것이 '동기화(Synchronization)'이다.

    임계영역은 어디?

    일반적으로 임계영역은 쓰레드에 의해 실행되는 함수 내에 존재한다.

    void* thread_int(void* arg)

    {

    int i;

    for( i = 0 ; i < 500000; i++ )

    num += 1; // 임계 영역

    return NULL;

    }

    void* thread_dec(void* arg)

    {

    int i;

    for( i = 0 ; i < 500000 ; i++ )

    num -= 1; // 임계 영역

    return NULL;

    }

    위 코드의 주석에서 말하듯이 변수 num이 아닌, 변수 num에 접근하는 두 문장이 임계영역에 해당한다.

    문제가 발생하는 상황은 세 가지 형태로 나눠 정리할 수 있다.

    • 두 쓰레드가 동시에 thread_inc 함수를 실행하는 경우
    • 두 쓰레드가 동시에 thread_dec 함수를 실행하는 경우
    • 두 쓰레드가 각각 thread_inc 함수와 thread_dec함수를 동시에 실행하는 경우

    이렇듯 임계영역은 서로 다른 두 문장이 각각 다른 쓰레드에 의해서 동시에 실행되는 상황에서도 만들어 질 수 있다.

  • 쓰레드 동기화

    동기화의 두 가지 측면

    쓰레드의 동기화는 쓰레드의 접근순서 때문에 발생하는 문제점의 해결책을 뜻한다. 그런데 동기화가 필요한 상황은 다음 두 가지 측면에서 생각해 볼 수 있다.

    • 동일한 메모리 영역으로의 동시접근이 발생하는 상황
    • 동일한 메모리 영역에 접근하는 쓰레드의 실행순서를 지정해야 하는 상황

    두 번째 상황은 쓰레드의 '실행 순서 컨트롤(Contrl)'에 관련된 내용이다. 실행 순서의 컨트롤이 필요한 상황에서도 동기화 기법이 활용된다.

    뮤텍스(Mutex)

    뮤텍스란 'Mutual Exclusion'의 줄임 말로써 쓰레드의 동시접근을 허용하지 않는다는 의미가 있다. 뮤텍스는 쓰레드의 동기접근에 대한 해결책으로 주로 사용된다.

    뮤텍스라 불리는 자물쇠 시스템의 생성 및 소멸 함수는 다음과 같다.

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* attr);
int pthread_mutex_destroy(pthread_mutex_t* mutex);
// 성공 : 0
// 실패 : 0이외의 값 반환

  • mutex

    뮤텍스 생성시에는 뮤텍스의 참조 값 저장을 위한 변수의 주소값 전달, 소멸시에는 소멸하고자 하는 뮤텍스의 참조값을 저장하고 있는 변수의 주소 값 전달

  • attr

    생성하는 뮤텍스의 특성정보를 담고 있는 변수의 주소 값 전달, 별도의 특성을 지정하지 않을 경우 NULL 전달

  • 쓰레드의 소멸과 멀티쓰레드 기반의 다중접속 서버의 구현
반응형