책정리/윈도우 네트워크 프로그래밍

5장 멀티스레드

GONII 2015. 4. 20. 15:22
  • 멀티스레드 기초

    4장에서 다룬 TCP 서버/클라이언트 예제는 다음과 같은 문제가 있다

    • 두 개 이상의 클라이언트가 서버에 접속할 수 있으나, 서버가 동시에 두 개 이상의 클라이언트에 대한 서비스를 할 수 없다.
    • 서버와 클라이언트의 send( ), recv( )함수 호출 순서가 서로 맞아야 한다. 데이터를 보내지 않은 상태에서 양쪽에서 동시에 recv( )함수를 호출할 경우 교착 상태가 발생할 수 있다. 여기서 교착 상태(deadlock)란 영원히 일어나지 않을 사건을 두 프로세스가 기다린느 상황을 뜻한다. 아래 경우 두 프로세스가 recv( )함수에서 빠져 나가지 못하고, 이로 인해 send( )함수를 호출할 수 없으므로 교착 상태가 발생한다.

    첫 번째 문제점에 대한 해결책과 각각의 장단점은 다음과 같다

    • 서버가 하나의 클라이언트와 통신하는 시간을 짧게 준다. 즉 클라이언트는 매번 데이터를 보내기 전에 서버에 접속하고 데이터를 주고받은 후 곧바로 접속을 끊는다
      • 장점 : 특별한 기법을 도입하지 않고 곧바로 구현할 수 있다. 서버의 시스템 자원을 적게 소모한다.
      • 단점 : 파일 전송 프로그램과 같이 대용량 데이터를 전송하는 애플리케이션은 적합하지 않다. 또한 서버에 접속하려는 클라이언트 수가 많을 수록 접속 실패가 발생할 확률이 높다
    • 서버에 접속한 클라이언트를 스레드를 이용하여 독립적으로 처리한다
      • 장점 : 비교적 쉽게 구현할 수 있다
      • 단점 : 접속한 클라이언트 수에 비례하여 스레드를 생성하므로 서버의 시스템 자원 소모가 상대적으로 크다
    • 소켓 입출력 모델을 사용한다
      • 장점 : 하나 또는 소수의 스레드를 이용하여 다수의 클라이언트를 처리할 수 있으므로 두 번째 방법에 비해 서버의 시스템 자원 소모가 상대적으로 적다
      • 단점 : 다른 두 방법에 비해 구현이 어렵다

    두 번째 문제점에 대한 해결책과 각각의 장단점은 다음과 같다

    • 데이터 송수신 부분을 잘 설계하여 교착 상태가 발생하지 않도록 한다
      • 장점 : 특별한 기법을 도입하지 않고 곧바로 구현할 수 있다
      • 단점 : 데이터 송수신 패턴에 따라 교착 상태가 발생할 수도 있다. 따라서 이 방법을 모든 경우에 적용할 수는 없다
    • 소켓에 타임아웃(timeout) 옵션을 적용하여, send( )/recv( ) 함수 호출 시 일정 시간이 지나면 리턴하도록 한다
      • 장점 : 비교적 간단하게 구현할 수 있다
      • 단점 : 다른 방법에 비해 성능이 떨어진다
    • 넌블로킹(nonblocking) 소켓을 사용한다
      • 장점 : 교착 상태를 막을 수 있다
      • 단점 : 구현이 복잡하다. 시스템 자원(특히 CPU시간)을 불필요하게 낭비할 가능성이 있다
    • 소켓 입출력 모델을 사용한다
      • 장점 : 블로킹과 넌블로킹 소켓의 단점을 보완하고, 교착 상태를 막을 수 있다.
      • 단점 : 첫번째 방법에 비해 구현이 어렵다. 그러나 두 번째나 세 번째 방법보다는 쉽고 일관성 있게 구현할 수 있다.

      기본 개념

      윈도우에서는 일반적인 의미의 프로세스 개념을 프로세스와 스레드 두개로 구분하고 있다. 프로세스(process)는 메모리를 비롯한 각종 리소스를 담고 있는 컨테이너(container)로서 정적인 개념이고, 스레드(thread)는 실제 CPU 시간을 할당받아 수행되는 실행 단위로서 동적인 개념이다. 즉 프로세스는 메모리를 비롯한 각종 리소스를 담고 있고, 프로세스 내에 존재하는 스레드가 이들 리소스를 사용하여 필요한 작업을 수행하는 것이다.

      하나의 윈도우 애플리케이션이 실행되기 위해서는 최소 하나의 스레드가 필요하다. main( )또는 WinMain( )함수에서 시작되는 스레드를 주 스레드(primary thread)라 부르며, 프로세스가 시작할 때 생성된다. 만약 애플리케이션이 새로운 작업을 주 스레드와 병렬적으로 수행해야 한다면, 추가적인 스레드를 생성하고, 이 스레드가 해당 작업을 수행하도록 하면 된다.

      하나의 CPU가 두 개 이상의 스레드를 교대로 실행하기 위해서는 각 스레드의 최종 실행 상태를 저장하고 복원하는 작업을 반복해야 한다. 여기서 스레드의 실행 상태란 CPU레지스터 값과 스택을 비롯한 각종 정보를 의미한다. CPU와 운영체제의 협동으로 이루어지는 스레드 실행 상태의 저장과 복원 작업을 컨텍스트 전환(context switch)라고 부르며, 이로 인해 각 스레드는 다른 스레드의 존재와 무관하게 자신의 상태를 유지하면서 실행될 수 있다.

      하나의 애플리케이션(프로세스)에서 두 개의 스레드를 사용하는 원리는 다음과 같다

      • 스레드1이 실행 중이다. 명령을 처리할 때마다 CPU레지스터 값과 스택 내용이 변경된다.

      • 스레드 1의 실행을 중지하고 실행 상태를 저장한다. 이전에 저장해두었던 스레드2의 상태를 복원한다.

      • 스레드2를 실행한다. 명령을 처리할 때마다 CPU레지스터 값과 내용이 변경된다.

      • 스레드2의 실행을 중지하고 실행 상태를 저장한다. 이전에 저장해두었던 스레드1의 상태를 복원한다.

      • 스레드1을 다시 실행한다. 이전 상태가 복원되었으므로 스레드1은 마지막으로 수행한 명령 다음 위치부터 실행할 것이다.

      윈도우 애플리케이션은 크게 콘솔과 GUI(Graphic User Interface)로 나눌 수 있는데, GUI애플리케이션은 메시지 구동 구조(message-driven architecture)라는 독특한 특징 때문에 반드시 멀티스레드를 사용해야 하는 경우가 생긴다.

      스레드 생성과 종료

      프로세스가 생성되면 main( )함수를 실행 시작점으로 하는 주 스레드가 자동으로 생성된다. 이때 또 다른 함수인 f( )를 실행 시작점으로 하는 스레드를 생성하려면 다음과 같은 정보를 운영체제에 제공해야 한다.

      • f( )함수의 시작 주소

        운영체제는 실행 파일에서 f( )함수의 시작 위치값을 알아야 한다. 소스 코드 수준에서는 f( )함수의 이름이 곧 시작 주소가 된다. f( )함수와 같이 스레드 실행 시작점이 되는 함수를 스레드 함수(thread function)라 부른다

      • f( )함수 실행 시 사용할 스택 영역의 크기

        모든 함수는 실행 중 인자 전달과 변수 사용을 위해 스택을 필요로 한다. 만약 f( )함수를 실행 시작점으로 하는 스레드 두 개를 생성하면, 서로 다른 메모리 영역에 두 스레드를 위한 스택이 각각 할당된다.

      윈도우에서 스레드를 생성하려면 CreateThread( )함수를 사용한다. CreateThread( )함수는 스레드를 생성한 후 스레드 핸들(thread handle)을 리턴한다. 스레드 핸들은 파일 디스크립터나 소켓 디스크립터와 비슷한 개념으로, 스레드 관련 데이터 구조체를 간접적으로 참조하는 매개체가 된다. 스레드 핸들을 가지고 있음녀 윈도우 API를 이용하여 해당 스레드를 다양한 방식으로 제어할 수 있다.

      HANDLE CreateThread(

      LPSECURITY_ATTRIBUTES lpThreadAttributes,

      SIZE_T dwStackSize,

      LPTHREAD_START_ROUTINE lpStartAddress,

      LPVOID lpParameter,

      DWORD dwCreationFalgs,

      LPDWORD lpThreadID

      ); // 성공:스레드 핸들, 실패:NULL

      • lpThreadAttributes

        SECURITY_ATTRIBUTES 구조체 변수의 주소값을 대입한다. SECURITY_ATTRIBUTES구조체는 핸들 상속(handle inheritance)과 보안 디스크립터(security descriptor)정보를 전달하는 용도로 사용된다.

      • dwStackSize

        새로 생성할 스레드에 할당되는 스택 크기이다. 0을 사용하면 디폴트로 1MB가 할당된다.

      • lpStartAddress

        스레드 함수의 시작 주소다. 스레드 함수는 반드시 다음과 같은 형태로 정의해야 한다

        DWORD WINAPI ThreadProc(LPVOID lpParamter)

        {

        ...

        }

      • lpParameter

        스레드 함수에 전달할 인자다. void형 포인터므로 32비트 크기의 값 하나만 전달할 수 있다. 32비트보다 큰 값을 전달할 때는 구조체 변수에 값을 넣어 이 구조체의 주소값을 전달하면된다. 전달할 인자가 없다면 NULL값을 사용한다.

      • dwCreationFalgs

        스레드 생성을 제어하는 값으로 0 또는 CREATE_SUSPENDED를 사용한다. 0을 사용하면 스레드 생성 후 곧바로 실행되고, CREATE_SUSPENDED를 사용하면 스레드 생성은 되지만 ResumeThread( )함수를 호출하기 전까지는 실행되지 않는다.

      • lpThreadID

        DWORD형 변수 주소값으로, 이 변수에 스레드 ID가 필요하지 않다면 윈도우NT계열에서는 NULL값을 사용해도 된다.

      윈도우에서 스레드를 종료하는 방법은 다음과 같이 네 가지로 요약할 수 있다.

      • 스레드 함수가 리턴한다.
      • 스레드 함수 내에서 ExitThread( )함수를 호출한다.
      • TerminateThread( )함수를 호출한다.
      • 주 스레드가 종료하면 모든 스레드가 종료된다.

      일반적으로 1 또는 2에 의해 스레드를 종료하는 것이 바람직하다. 3은 극단적인 경우에만 사용해야 하며 4는 스레드를 종료하는 정상적인 방법이라기보다는 주 스레드의 특성으로 이해하면 된다. ExitThread( ), TerminateThread( )함수 원형은 다음과 같다

void ExitThread(

DWORD dwExitCod // 종료코드

);

BOOL TerminateThread(

HANDLE hThread, // 종료할 스레드를 가리키는 핸들

DWORD dwExitCode // 종료코드

); // 성공:0이 아닌 값, 실패:0

  • 예제 ExThread1

#include <windows.h>

#include <stdio.h>

   

struct Point3D

{

int x, y, z;

};

   

DWORD WINAPI myThread(LPVOID arg)

{

Point3D *pt = (Point3D*)arg;

while(1)

{

printf("Running another thread: %d, %d, %d\n",

pt->x, pt->y, pt->z);

Sleep(1000);

}

   

return 0;

}

   

int main(void)

{

// 첫번째 스레드 생성

Point3D pt1 = {10, 20, 30};

DWORD threadId1;

HANDLE hThread1 = CreateThread(NULL, 0, myThread, (LPVOID)&pt1, 0, &threadId1);

if( hThread1 == NULL )

{

return -1;

}

CloseHandle(hThread1);

   

// 두번째 스레드 생성

Point3D pt2 = {40, 50, 60};

DWORD threadId2;

HANDLE hThread2 = CreateThread(NULL, 0, myThread, (LPVOID)&pt2, 0, &threadId2);

if( hThread2 == NULL )

{

return -1;

}

CloseHandle(hThread2);

   

while(1)

{

printf("Running primary thread...\n");

Sleep(1000);

}

   

return 0;

}

스레드 조작 함수

스레드는 윈도우 애플리케이션의 실행 단위므로, 우선 순위를 변경하거나 실행을 중지하고 재실행 하는 등의 작업을 윈도우 API수준에서 지원한다.

  • 우선순위 변경

    윈도우는 각 스레드에 CPU시간을 적절히 분배하기 위한 정책을 사용하는데, 이를 스레드 스케줄링(thread scheduling) 또는 CPU 스케줄링(CPU scheduling)이라 부른다.

    윈도우의 스케줄링 기법은 우선 순위(priority)에 기반한 것으로, 우선 순위가 높은 스레드에 우선적으로 CPU 시간을 할당한다. 스레드의 우선 순위를 결정하는 요소는 다음과 같다.

    • 프로세스 우선 순위 : 우선 순위 클래스(priority class)
    • 스레드 우선 순위 : 우선 순위 레벨(priority level)

    우선 순위 클래스는 프로세스 속성으로 한 프로세스가 생성한 스레드는 모두 동일한 우선 순위 클래스를 가진다. 윈도우 운영체제에서 제공하는 우선 순위 클래스는 다음과 같다

    • REALTIME_PRIORITY_CLASS(실시간)
    • HIGH_PRIORITY_CLASS(높음)
    • ABOVE_NORMAL_PRIORITY_CLASS(보통 초과; 윈도우 2000/XP/2003)
    • NORMAL_PRIORITY_CLASS(보통)
    • BELOW_NORMAL_PRIORITY_CLASS(보통 미만; 윈도우2000/XP/2003)
    • IDLE_PRIORITY_CLASS(낮음)

    우선 순위 레벨은 스레드 속성으로, 같은 프로세스에 속한 스레드 사이에서 상대적인 우선 순위를 결정할 때 사용한다. 윈도우 운영체제에서 제공하는 우선 순위 레벨은 다음과 같다

    • THREAD_PRIORITY_TIME_CRITICAL
    • THREAD_PRIORITY_HIGHEST
    • THREAD_PRIORITY_ABOVE_NORMAL
    • THREAD_PRIORITY_NORMAL
    • THREAD_PRIORITY_BELOW_NORMAL
    • THREAD_PRIORITY_LOWEST
    • THREAD_PRIORITY_IDLE

    우선 순위 클래스와 우선 순위 레벨을 결합하면 스레드의 기초 우선 순위(base priority)가 결정되고, 이 값이 스레드 스케줄링에 사용된다. 윈도우의 스케줄링 방식은 우선 순위가 가장 높은 스레드에 CPU시간을 할당하고, 같은 우선 순위 스레드가 여러개 있을 때는 교대로 CPU 시간을 할당한다.

    우선 순위가 높은 스레드가 계속 CPU 시간을 요구할 경우 우선 순위가 낮은 스레드는 CPU 시간을 계속해서 할당받지 못하는 문제가 있다. 이와 같은 상황을 기아(starvation)라고 한다. 기아 문제를 해결하기 위해 윈도우는 스레드 우선 순위를 상황에 따라 동적으로 바꿈으로써 우선 순위가 낮은 스레드도 궁극적으로 CPU를 사용할 수 있게 만든다. 또한 현재 사용자가 작업하고 있는 프로그램의 반응 속도를 빠르게 하기 위해 동적으로 우선 순위를 변화시키기도 한다.

    멀티스레드를 사용할 때는 작업의 중요도에 따라 주로 우선 순위 레벨을 변경하며, 우선 순위 클래스를 변경하는 경우는 드물다. 우선 순위 레벨과 관련된 API함수는 다음과 같다.

    BOOL SetThreadPriority(

    HANDLE hThread, // 스레드 핸들

    int nPriority // 우선 순위 레벨 값

    ); // 성공: 0이아닌 값, 실패 : 0

    int GetThreadPriority(

    HANDLE hThread // 스레드 핸들

    ); // 성공:우선 순위 레벨 값, 실패: THREAD_PRIORITY_ERROR_RETURN

  • 예제 ExThread2

#include <windows.h>

#include <stdio.h>

   

DWORD WINAPI MyThread(LPVOID arg)

{

while(1)

{

printf("running myThread()\n");

}

   

return 0;

}

   

int main(void)

{

// 스레드 생성

HANDLE hThread = CreateThread(NULL, 0, MyThread, NULL, 0, NULL);

if( hThread == NULL )

{

return -1;

}

   

// 우선 순위 변경

SetThreadPriority(hThread, THREAD_PRIORITY_ABOVE_NORMAL);

   

while(1)

{

printf("Running main()...\n");

}

   

return 0;

}

MyThread()의 우선 순위를 main()보다 높게 설정했고, 무한 루프를 돌면서 CPU시간을 계속 요구하므로 우선 순위에 따르면 MyThread()함수만 CPU시간을 할당받게 된다. 이 경우 기아(starvation)가 발생하지만 윈도우가 우선 순위를 동적으로 변화시키고 있기 때문에 main함수도 실행되고 있다.

  • 스레드 종료 기다리기

    스레드는 일단 생성되면 CPU시간을 사용하기 위해 다른 스레드와 경쟁하면서 독립적으로 실행된다. 그러나 때로는 스레드가 작업을 완료하고 종료했는지 여부를 확인해야 할 경우가 생긴다. WaitForSingleObject( ) 함수를 사용하면 특정 스레드가 종료할 때까지 대기할 수 있다.

    DWORD WaitForSingleObject(

    HANDLE hHandle,

    DWORD dwMilliseconds

    ); // 성공:WAIT_OBJECT_0 또는 WAIT_TIMEOUT, 실패 : WAIT_FAILED

    • hHandle

      종료를 기다릴 스레드를 가리키는 핸들

    • dwMilliseconds

      대기 시간으로 밀리초 단위를 사용한다. 이 시간 내에 스레드가 종료하지 않더라고 WaitForSingleObject( )함수는 리턴하고, 이때 리턴값은 WAIT_TIMEOUT이 된다. 스레드가 종료한 경우에는 WAIT_OBJECT_0가 리턴된다. 대기 시간으로 INFINITE값을 사용하면 스레드가 종료할 때까지 무한히 대기한다.

    여러 스레드가 종료되기를 기다리려면 WaitForMultipleObjects( )함수를 사용하면 된다.

    DWORD WiatForMultipleObjects(

    DWORD nCount,

    const HANDLE* lpHandles,

    BOOL bWaitAll,

    DWORD dwMilliseconds

    );

    성공 : WAIT_OBJECT_0 ~ WAIT_OBJECT_0 + nCount-1 또는 WAIT_TIMEOUT, 실패 : WAIT_FAILED

    • nCount, lpHandles

      WaitForMultipleObjects( )함수를 사용할 때는 스레드 핸들을 모두 배열에 저장해야 한다. nCount는 배열 원소 개수, lpHandles는 배열의 시작 주소를 나타낸다. nCount최대값은 MAXIMUM_WAIT_OBJECTS(=64)이다.

    • bWaitAll

      TRUE이면 모든 스레드가 종료할 때까지 대기한다. FALSE이면 한 스레드가 종료하는 즉시 리턴한다.

    • dwMilliseconds

      사용법과 의미는 WaitForSingleObject( )함수의 두 번째 인자와 동일하다.

  • 실행 중지와 재실행

    SuspendThread( )함수는 스레드 실행을 일시 중지하고, ResumThread( )함수는 스레드를 재실행한다. 운영체제는 스레드의 중지 횟수(suspend count)를 유지하면서 SuspendThread( )함수를 호출할 때마다 이 값을 1씩 증가시키고, ResumThread( )함수를 호출할 때마다 1씩 감소시킨다. 중지 횟수가 0보다 크면 스레드 실행이 중지된다.

    DWORD SuspendThread(

    HANDLE hThread // 스레드 핸들

    ); // 성공:중지 횟수, 실패:-1

    DWORD ResumeThread(

    HANDLE hThread // 스레드 핸들

    ); // 성공:중지 횟수, 실패:-1

    Sleep( )함수는 스레드가 실행을 멈추고 일정 시간 동안 대기하도록 한다. 해등 스레드는 적어도 dwMilliseconds로 지정한 시간이 지나야 깨어난다.

    void Sleep(

    DWORD dwMilliseconds // 밀리초(ms)

    );

  • 예제 ExThread3

#include <windows.h>

#include <stdio.h>

   

int sum = 0;

   

DWORD WINAPI MyThread(LPVOID arg)

{

int num = (int)arg;

for( int i = 1 ; i <= num ; i++ )

{

sum += i;

}

return 0;

}

   

int main(void)

{

// 스레드 생성

int num = 100;

HANDLE hThread = CreateThread(NULL, 0, MyThread, (LPVOID)num, CREATE_SUSPENDED, NULL);

   

if( hThread == NULL )

{

return -1;

}

   

printf("스레드 실행 전. 계산 결과=%d\n", sum);

ResumeThread(hThread);

WaitForSingleObject(hThread, INFINITE);

printf("스레드 실행 후. 계산 결과=%d\n", sum);

   

CloseHandle(hThread);

return 0;

}

  • 멀티스레드 TCP 서버

    멀티스레드 TCP서버의 기본 형태는 다음과 같다

DWORD WINAPI ProcessClient(LPVOID arg)

{

// 전달된 소켓 3

SOCKET client_sock = (SOCKET)arg;

   

// 클라이언트 정보 얻기 4

addrlen = sizeof(clientaddr);

getpeername(client_sock, (SOCKADDR*)&clientaddr, &addrlen);

   

// 클라이언트와 데이터 통신 5

while(1)

{

...

}

closesocket(client_sock);

return 0;

}

   

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

{

...

while(1)

{

// 클라이언트 접속 수용 1

client_sock = accept(listen_sock, ...);

...

// 스레드 생성 2

CreateThread(NULL, 0, ProcessClient, (LPVOID)client_sock, 0, &threadID);

}

...

}

  • 클라이언트가 접속하면 accept( )함수는 소켓을 리턴한다.
  • 클라이언트 당 하나의 스레드를 생성한다. 이때 스레드 함수에 소켓을 넘겨준다.
  • 스레드 함수는 인자로 전달된 소켓을 SOCKET타입으로 캐스팅(casting)하여 저장해둔다.
  • getpeername( )함수를 호출하여 클라이언트 IP주소와 포트 번호를 얻는다. 이 코드는 필수적인 부분은 아니며, 클라이언트 정보 출력을 원할 때만 필요하다.
  • 클라이언트와 통신을 수행한 후 접속을 종료한다.

소켓만 가지고 있을 때 이 소켓과 연관된 주소 정보를 리턴하는 함수가 있다.

int getpeername(

SOCKET s,

struct sockaddr* name,

int* namelen

); // 성공:0, 실패:SOCKET_ERROR

int getsockname(

SOCKET s,

struct sockaddr* name,

int* namelen

); // 성공:0, 실패:SOCKET_ERROR

getpeername( )함수는 소켓 데이터 구조체에 저장된 원격 IP주소와 원격 포트 번호를 리턴한다. 반면 getsockname( )함수는 소켓 데이터 구조체에 저장된 지역IP주소와 지역 포트 번호를 리턴한다.

  • 예제 TCPServer2

#include <winsock2.h>

#include <stdlib.h>

#include <stdio.h>

   

#pragma comment(lib, "ws2_32.lib")

   

#define BUFSIZE 512

   

// 소켓 함수 오류 후 종료

void err_quit(char *msg)

{

LPVOID lpMsgBuf;

FormatMessage(

FORMAT_MESSAGE_FROM_SYSTEM,

NULL, WSAGetLastError(),

MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),

(LPTSTR)&lpMsgBuf, 0, NULL);

   

MessageBox(NULL, (LPCTSTR)lpMsgBuf, msg, MB_ICONERROR);

LocalFree(lpMsgBuf);

exit(-1);

}

   

// 소켓 함수 오류 출력

void err_display(char* msg)

{

LPVOID lpMsgBuf;

FormatMessage(

FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM,

NULL, WSAGetLastError(),

MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),

(LPTSTR)&lpMsgBuf, 0, NULL);

   

printf("[%s] %s", msg, (LPCTSTR)lpMsgBuf);

LocalFree(lpMsgBuf);

}

   

// 클라이언트와 데이터 통신

DWORD WINAPI processClient(LPVOID arg)

{

SOCKET client_sock = (SOCKET)arg;

char buf[BUFSIZE+1];

SOCKADDR_IN clientaddr;

int addrlen;

int retval;

   

// 클라이언트 정보 얻기

addrlen = sizeof(clientaddr);

getpeername(client_sock, (SOCKADDR*)&clientaddr, &addrlen);

   

while(1)

{

// 데이터 받기

retval = recv(client_sock, buf, BUFSIZE, 0);

//

if( retval == SOCKET_ERROR )

{

err_display("recv()");

break;

}

else if( retval == 0 )

{

break;

}

   

// 받은 데이터 출력

buf[retval] = '\0';

printf("[TCP/%s:%d] %s\n",

inet_ntoa(clientaddr.sin_addr),

ntohs(clientaddr.sin_port), buf);

   

// 데이터 보내기

retval = send(client_sock, buf, retval, 0);

if( retval == SOCKET_ERROR )

{

err_display("send()");

break;

}

}

   

// closesocket()

closesocket(client_sock);

   

printf("[TCP서버] 클라이언트 종료 : IP주소=%s, 포트번호=%d\n",

inet_ntoa(clientaddr.sin_addr),

ntohs(clientaddr.sin_port));

   

return 0;

}

   

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

{

int retval;

   

// 윈속 초기화

WSADATA wsa;

if( WSAStartup(MAKEWORD(2,2), &wsa) != 0 )

{

return -1;

}

   

// socket()

SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, 0);

if( listen_sock == INVALID_SOCKET ) err_quit("socket()");

   

//bind

SOCKADDR_IN serveraddr;

ZeroMemory(&serveraddr, sizeof(serveraddr));

serveraddr.sin_family = AF_INET;

serveraddr.sin_port = htons(9000);

serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);

retval = bind(listen_sock, (SOCKADDR*)&serveraddr, sizeof(serveraddr));

if( retval == SOCKET_ERROR) err_quit("bind()");

   

//listen()

retval = listen(listen_sock, SOMAXCONN);

if( retval == SOCKET_ERROR) err_quit("listen()");

   

// 데이터 통신에 사용할 변수

SOCKET client_sock;

SOCKADDR_IN clientaddr;

int addrlen;

HANDLE hThread;

DWORD threadID;

   

while(1)

{

// accept()

addrlen = sizeof(clientaddr);

client_sock = accept(listen_sock, (SOCKADDR*)&clientaddr, &addrlen);

if(client_sock == INVALID_SOCKET)

{

err_display("accept()");

continue;

}

   

printf("[TCP서버] 클라이언트 접속 : IP주소=%s, 포트번호=%d\n",

inet_ntoa(clientaddr.sin_addr),

ntohs(clientaddr.sin_port));

   

// 스레드 생성

hThread = CreateThread(NULL, 0, processClient, (LPVOID)client_sock, 0, &threadID);

if( hThread == NULL )

{

printf("[오류] 스레드 생성 실패\n");

}

else

{

CloseHandle(hThread);

}

}

   

// closesocket()

closesocket(listen_sock);

   

// 윈속 종료

WSACleanup();

   

return 0;

}

  • 스레드 동기화

    멀티스레드를 사용하는 프로그램에서 두 개 이상의 스레드가 공유 데이터를 접근하는 경우 다양한 문제가 발생할 수 있다.

    멀티스레드 환경에서 발생하는 문제를 해결하기 위한 일련의 작업을 스레드 동기화(thread synchronization)라 부른다. 윈도우는 다양한 동기화 관련 API를 제공하며 프로그래머가 상황에 따라 적절한 방법을 선택할 수 있도록 한다.

    • 스레드 동기화 기법

종류

주요 용도

임계 영역(critical section)

공유 리소스에 대해 오직 하나의 스레드 접근만 허용한다.(한 프로세스에 속한 스레드에만 사용 가능)

뮤텍스(mutex)

공유 리소스에 대해 오직 하나의 스레드 접근만 허용한다.(서로 다른 프로세스에 속한 스레드에도 사용 가능)

이벤트(event)

특정 사건 발생을 다른 스레드에 알린다.

세마포어(semaphore)

한정된 개수의 자원을 여러 스레드가 사용하려고 할 때 접근을 제한한다.

대기 기능 타이머(waitable timer)

특정 시간이 되면 대기 중인 스레드를 깨운다.

기본 개념

스레드 동기화가 필요한 상황을 크게 두 종류로 나눌 수 있다.

  • 두 개 이상의 스레드가 공유 리소스를 접근할 때, 오직 한 스레드만 접근을 허용해야 하는 경우
  • 특정 사건 발생을 다른 스레드에 알리는 경우. 예를 들면, 한 스레드가 작업을 완료한 후, 대기 중인 다른 스레드를 깨우는 경우

두 스레드가 동시에 진행해서는 안되는 상황이 있을 때 두 스레드 모두 매개체를 통해 진행 가능 여부를 판단하고, 이에 근거하여 진행을 계속하거나 대기한다.

윈도우에서 이러한 매개체 역할을 할 수 있는 것을 통틀어 동기화 객체(synchronization object)라 부른다. 동기화 객체의 특징을 요약하면 다음과 같다

  • Create*( )함수를 호출하면 커널(kernel;운영체제) 영역 메모리에 동기화 객체가 생성되고, 이를 접근할 수 있도록 핸들(HANDLE타입)을 리턴한다.
  • 평소에는 비신호 상태(non-signaled state)로 있다가 특정 조건이 만족되면 신호 상태(signaled state)가 된다. 비신호->신호 상태 변화는 Wait*( )함수를 사용하여 감지할 수 있다.
  • 사용이 끝나면 CloseHandle( )함수를 호출한다.

Wait*( )함수는 스레드 동기화를 위한 필수 함수로서, 자주 사용하는 WaitForSingleObject( ), WiatForMultipleObject( )함수가 있다. 동기화 객체를 공부할 땐느 비신호->신호, 신호->비신호 상태 변화 조건을 잘 이해해야 하며, 상황에 맞게 Wiat*( )함수를 사용할 수 있도록 연습해야 한다.

임계 영역

임계 영역(critical section)은 두 개 이상의 스레드가 공유 리소스를 접근할 때, 오직 한 스레드 접근만 허용해야 하는 경우에 사용한다. 임계 영역은 스레드 동기화를 위해 사용하지만 동기화 객체로 분류하지 않으며, 특징 또한 다르다.

대표적인 특징은 다음과 같다.

  • 임계 영역은 유저(user) 영역 메모리에 존재하는 구조체다. 따라서 다른 프로세스가 접근할 수 없으므로, 한 프로세스에 속한 스레드 동기화에만 사용할 수 있다.
  • 일반적인 동기화 객체보다 빠르고 효율적이다.

임계 영역 사용 예는 다음과 같다

#include <windwos.h>

   

CRITICAL_SECTION cs; // 1

   

// 스레드1

DWORD WINAPI Thread1(LPVOID arg)

{

...

EnterCriticalSection(&cs); // 3

// 공유 리소스 접근

LeaveCriticalSection(&cs); // 4

...

}

   

// 스레드 2

DWORD WINAPI Thread2(LPVOID arg)

{

...

EnterCriticalSection(&cs); // 3

// 공유 리소스 접근

LeaveCriticalSection(&cs); // 4

...

}

   

int main(void)

{

InitializeCriticalSection(&cs); // 2

// 스레드 생성과 종료

DeleteCriticalSection(&cs); // 5

...

}

  • CRITICAL_SECTION 구조체 변수를 전역 변수로 선언한다. 일반적인 동기화 객체는 Create*( )함수를 호출함으로써 운영체제가 메모리를 할당하지만, 임계 영역은 이와 같이 애플리케이션이 직접 메모리를 할당한다.
  • 임계 영역을 사용하기 전에 InitializeCriticalSection( )함수를 호출하여 초기화한다.
  • 공유 리소스를 사용하기 전에 EnterCriticalSection( )함수를 호출한다. 공유 리소스를 사용하고 있는 스레드가 없다면 EnterCriticalSection( )함수는 곧바로 리턴한다. 공유 리소스를 사용하고 있는 스레드가 있다면 EnterCriticalSection( )함수는 리턴하지 않고, 해당 스레드는 대기 상태가 된다.
  • 공유 리소스 사용이 끝나면 LeaveCriticalSection( )함수를 호출한다.
  • 임계 영역 사용이 끝나면 DeleteCriticalSection( )함수를 호출한다.

임계 영역을 이용하면 공유 리소스 접근을 제한하는 것으로 스레드 동기화 문제를 해결했다고 할 수 없다. 즉 어느 스레드가 먼저 EnterCriticalSection( )함수를 성공적으로 호출할지 알 수 없다

  • 예제 ExCriticalSection

#include <windows.h>

#include <stdio.h>

   

int A[100];

CRITICAL_SECTION cs;

   

DWORD WINAPI myThread1(LPVOID arg)

{

EnterCriticalSection(&cs);

for( int i = 0 ; i < 100 ; i++ )

{

A[i] = 3;

Sleep(10);

}

LeaveCriticalSection(&cs);

   

return 0;

}

   

DWORD WINAPI myThread2(LPVOID arg)

{

EnterCriticalSection(&cs);

for( int i = 99 ; i >= 0 ; i-- )

{

A[i] = 4;

Sleep(10);

}

LeaveCriticalSection(&cs);

   

return 0;

}

   

int main(void)

{

// 임계 영역 초기화

InitializeCriticalSection(&cs);

   

// 두개의 스레드 생성

HANDLE hThread[2];

DWORD threadID[2];

   

hThread[0] = CreateThread(NULL, 0, myThread1, NULL, 0, &threadID[0]);

hThread[1] = CreateThread(NULL, 0, myThread2, NULL, 0, &threadID[1]);

   

// 스레드 종료 대기

WaitForMultipleObjects(2, hThread, TRUE, INFINITE);

   

// 임계 영역 제거

DeleteCriticalSection(&cs);

   

// 결과 출력

for( int i = 0 ; i < 100 ; i++ )

{

printf("%d ", A[i]);

}

printf("\n");

   

return 0;

}

이벤트

이벤트는 특정 사건의 발생을 다른 스레드에 알리는 경우에 주로 사용한다. 예를 들면, 한 스레드가 작업을 완료한 후, 대기 중인 다른 스레드가 깨어나서 진행하는 시나리오를 만들 때 사용할 수 있다. 이벤트를 이용한 동기화 예는 다음과 같다.

  • 이벤트를 비신호 상태로 생성한다.
  • 한 스레드가 작업을 진행하고, 나머지 스레드는 이벤트에 대해 Wait*( )함수를 호출함으로써 이벤트가 신호 상태가 되기를 기다린다.
  • 스레드가 작업을 완료하면, 이벤트를 신호 상태로 바꾼다.
  • 기다리고 있던 모든 스레드가 깨어나서 작업을 진행한다.

이벤트는 대표적인 동기화 객체로, 신호(signaled)와 비신호(nonsignaled)라는 두 가지 상태를 가지며 상태를 변경할 수 있도록 다음과 같은 함수를 제공한다.

BOOL SetEvent(HANDLE hEvent); // 비신호 상태 -> 신호 상태

BOOL ResetEvent(HANDLE hEvent); // 신호 상태 -> 비신호 상태

이벤트는 특성에 따라 두 가지 종류가 있다.

  • 자동 리셋(auto_reset) 이벤트

    이벤트를 신호 상태로 바꾸면, 기다리는 스레드 중 하나만 깨운 후 자동으로 비신호 상태가 된다. 따라서 자동 리셋 이벤트에 대해서는 ResetEvent( )함수를 사용할 필요가 없다.

  • 수동 리셋(manual-reset) 이벤트

    이벤트를 신호 상태로 바꾸면, 계속 신호 상태를 유지하므로 결과적으로 기다리는 스레드를 모두 깨우게 된다. 자동 리셋 이벤트와 달리 이벤트를 비신호 상태로 바꾸려면 명시적으로 ResetEvent( )함수를 호출해야 한다.

이벤트 생성 함수는 다음과 같다

HANDLE CreateEvent(

LPSECURITY_ATTRIBUTES lpEventAttributes,

BOOL bManualReset,

BOOL bInitialState,

LPCTSTR lpName

); // 성공:이벤트 핸들, 실패:NULL

  • lpEventAttributes

    SECURITY_ATTRIBUTES 구조체 변수의 주소값을 대입한다. SECURITY_ATTRIBUTES 구조체는 핸들 상속(handle inheritance)과 보안 디스크립터(security descriptor) 정보를 전달하는 용도로 사용하는데 대부분의 경우 NULL을 사용한다.

  • bManualReset

    TRUE면 수동 리셋 이벤트, FALSE면 자동 리셋 이벤트가 생성된다.

  • bInitialState

    TRUE면 신호 상태로, FALSE면 비신호 상태로 시작한다.

  • lpName

    이벤트를 서로 다른 프로세스에 속한 스레드가 사용(공유)할 수 있도록 이름을 줄 수 있다. NULL값을 사용하면 이름 없는 이벤트가 생성된다.

  • 예제 ExEvent

    공유 버퍼에 데이터를 쓰는 스레드 하나와 공유 버퍼로부터 데이터를 읽는 스레드 두 개를 생성하도록 한다. 한 스레드가 버퍼를 접근할 수 있도록 해야 함은 물론이고, 접근 순서도 정해야 한다. 스레드 실행 순서에 대한 제약 조건은 다음과 같다

    • 스레드1이 쓰기를 완료한 후 스레드2 또는 스레드3이 읽을 수 있다. 이때 스레드2와 스레드3 중 하나만 버퍼 데이터를 읽을 수 있으며, 둘 중 한 스레드가 읽으면 다른 스레드는 읽을 수 없다.
    • 스레드2 또는 3이 읽기를 완료한 후 스레드1이 다시 쓰기를 할 수 있다.

// 스레드 3개 종료 안됨

#include <windows.h>

#include <stdio.h>

   

#define BUFSIZE 16

   

HANDLE hReadEvent;

HANDLE hWriteEvent;

char buf[BUFSIZE];

   

DWORD WINAPI WriteThread(LPVOID arg)

{

DWORD retval;

   

for( int k = 0 ; k < 10 ; k++ )

{

// 읽기 완료를 기다림

retval = WaitForSingleObject(hReadEvent, INFINITE);

if(retval == WAIT_FAILED) break;

   

// 공유 버퍼에 데이터 쓰기

for( int i = 0 ; i < BUFSIZE ; i++ )

{

buf[i] = 3;

}

   

// 쓰기 완료를 알림

SetEvent(hWriteEvent);

}

   

// 이벤트 제거

CloseHandle(hWriteEvent);

   

return 0;

}

   

DWORD WINAPI ReadThread(LPVOID arg)

{

DWORD retval;

   

while(1)

{

// 쓰기 완료를 기다림

retval = WaitForSingleObject(hWriteEvent, INFINITE);

if(retval == WAIT_FAILED) break;

   

// 읽은 데이터를 출력

printf("thread %d:\t", GetCurrentThreadId());

for( int i = 0 ; i < BUFSIZE ; i++ )

{

printf("%d ", buf[i]);

}

printf("\n");

   

// 버퍼를 0으로 초기화

ZeroMemory(buf, sizeof(buf));

   

// 읽기 완료를 알림

SetEvent(hReadEvent);

}

   

return 0;

}

   

int main(void)

{

// 이벤트 생성

hReadEvent = CreateEvent(NULL, FALSE, TRUE, NULL);

if(hReadEvent == NULL) return -1;

hWriteEvent = CreateEvent(NULL, FALSE, FALSE, NULL);

if(hWriteEvent == NULL) return -1;

   

// 세 개의 스레드 생성

HANDLE hThread[3];

DWORD threadID[3];

   

hThread[0] = CreateThread(NULL, 0, WriteThread, NULL, 0, &threadID[0]);

hThread[1] = CreateThread(NULL, 0, ReadThread, NULL, 0, &threadID[1]);

hThread[2] = CreateThread(NULL, 0, ReadThread, NULL, 0, &threadID[2]);

   

// 스레드 종료 대기

WaitForMultipleObjects(3, hThread, TRUE, INFINITE);

   

// 이벤트 제거

CloseHandle(hReadEvent);

   

printf("모든 작업을 완료했습니다.\n");

   

return 0;

}

반응형