IOCP

1
2
3
4
커널 객체로써, 입출력 완료 포트(I/O Completion Port)이다.
특수한 목적으로 사용되며, 한정적인 객체.
프로세스 사이에서 IOCP 객체 공유는 불가능하며, 윈도우에서 제공하는 비동기 입출력을 위한 훌륭한 메커니즘이다.
IOCP는 입출력 완료에 대한 처리를 수행할 스레드를 몇 개 미리 생성하여 스레드 풀을 구성한 후, 입출력 완료 시마다 스레드 풀의 임의의 스레드를 출어서 끄집어 내 완료 후의 처리를 담당하게 한다.
  • 참고로 스레드는 디폴트로 1M의 스레드 스택을 가진다. 또한 스레드 문맥을 비록하여 자체 정보를 담기 위해 메모리를 소비한다.

  • IOCP는 가장 큰 특징은 요구사항을 처리해주는 스레드의 수를 제한할수 있다.

  • IOCP는 스레드를 적극적으로 활용하기 위해 만들어졌다.

  • IOCP는 기본적으로 스레드 풀의 형성이고, 따라서 최소 몇 개의 스레드를 전제하고 있다.

동작원리

IOCP 생성

1
2
3
4
5
6
7
CreateIoComepletionPort(
HANDLE FileHandle, // IOCP 연결할 핸들

HANDLE ExistingCompletionPort, // IOCP 핸들 없다면 NULL
ULONG_PTR CompletionKey, // IO 완료시 넘어갈 값, 사용자가 넘기고싶은값을 넘김.
DWORD NumberOfConcurrentThread // 한번에 동작할수 있는 최대 스레드 수, 0을 넣으면 자동으로 최적의 값을 넣어준다.
);

IO장치와 IOCP 연결

1
2
3
HANDLE hPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);        // IOCP Handle
HANDLE Port = CreateIoCompletionPort(
socket, hPort, (ULONG_PTR)id, 0);
  • 두번째 CreateIoCompletionPort를 호출하면서 IOCP 장치리스트에 새로운 레코드를 추가한다.

  • 삭제할 시점은 해당 장치의 핸들이 닫혔을때 이다.(위는 소켓을 썻으니 closesocket을 호출시켜야 된다.)

IO Completion Queue

1
2
3
4
5
이 큐는 IOCP와 연결한 Device의 IO 작업이 끝났음을 알려주는 큐이다.

스레드에서 IO Completion Queue에서 작업거리르 꺼내 IO완료에 따른 처리를 수행하게 한다.

GetQueuedCompletionStatus 함수를 호출 했을때, 당장 IO Comepletion Queue에 항목이 없거나 동시 수행 가능한 스레드 개수를 초과한 경우 대기 큐에 들어가 대기하고 있다가 상황이 오면 IO완료에 대한 처리를 수행하게 한다.

Page-Lock

1
2
3
4
5
6
7
8
WSASend, WSARecv 의 경우 상황에 따라 제공된 버퍼가 페이징 되지 않도록 Lock을 걸게 된다.

WSASend의 경우 소켓 버퍼가 가득찰 경우,
WSARecv의 경우 소켓버퍼로 부터 받을 데이터가 없을 경우일 것.

OS는 이렇게 잠겨진 메모리의 한계를 정의하고 있으며, 이 한계를 넘어가면 WSAENOBUFS로 Overlapped I/O는 실패하고 만다. 또 이렇게 전달된 유저 버퍼는 페이지 단위로 Lock를 걸게 된다. 1KB의 버퍼를 WSARecv 의 인자로 전달 했다면 OS는 4KB만큼 메모리를 잠근다는 뜻이다.

메모리 낭비를 줄이기 위해서는 버퍼를 페이지 단위(4KB의 배수)로 잡는것이 효율적이다.

Non-Paged Pool

1
2
3
4
5
6
7
Page-Locking이 일반 메모리를 페이징 안되도록 막는 개념이라면, Non-Paged Pool은 애초부터 물리 메모리에 상주하여 페이징이 안되도록 만들어놓은 영역이다. 

Page-Fault가 없다보니 접근 속도도 빠르며 고레벨 IRQL(Interrupt Request Level) 접근이 오류 없이 가능해진다. 그렇다보니 드라이버나 프로그렘들이 Non-Paged Pool을 남용하여 사용하게 되고, 제한된 리소스는 손쉽게 고갈되어 문제가 발생한다.

보통 Non-Paged Pool은 Windows 2000이상의 버전에서는 물리메모리의 1/4가 한계이다.

부족해지면 윈속함수에서 에러가 나오거나 운이 좋지않다면 시스템 에러로 손상될수 있다.

Non-Paged Pool의 사용되는 경우

  1. 드라이버와 같은 커널모드 컴포넌트에서 사용. WinSock, TCPIP.sys와 같은 프로토콜 드라이버도 이에 속함.
  2. 소켓을 생성할때마다 소켓의 상태 정보를 저장하기 위한 용도로도 작은 양의 Non-Paged Pool 소비한다.
  3. 소켓이 특정 주소로 바인딩되면, TCP/IP 스택은 로컬 주소 정보를 저장하기 위한 용도로도 Non-Paged Pool을 할당한다.
  4. Overlapped I/O 연산시 IRP(I/O Request Packet)을 발생시키면서 약 500 바이트의 Non-Paged Pool 할당한다.

Zero Byte Recv

1
2
3
4
5
6
7
Page Locking 최소화하기 위해 하는 방법.

일단 Overlapped I/O 작업이 일어나면 Page-Locking + Non-Paged Pool의 소비는 필수적이다.

0 바이트를 읽는 Recv 작업을 보내는 기법인데, 일단 읽는 크기가 0바이트이기 때문에 Page-Locking이 일어나지 않는다.

IOCP는 Proactor 방식이기 때문에 실제 읽기 작업이 일어나지 않고 있어도 WSARecv하고 있어야 하고, 이 때문에 메모리가 아무 작업도 안하는데 Page-Locking이 될수 있다는 걸 생각해보면 Zero Byte Recv가 굉장히 효율적인 방식이 될수 있다.

SO_RCVBUF, SO_SNDBUF

1
송수신 버퍼 크기를 0으로 지정한다면, 커널 레벨에서의 송수신 버퍼를 이용하지 않고, App으로 바로 복사가 일어나므로 복사 횟수는 한번 줄어들고 속도도 굉장히 빨라진다. 물론 버퍼로의 직접 복사 때문에 Page-Locking은 일어난다. (이 방법의 문제는 Recv에서는 문제가 있다. 못받는 틈이 존재하게 됨.)

Thread

1
2
3
4
5
멀티스레드 프로그래밍이란 간단히 말해서 동일한 애플리케이션 안에서 두 갱 ㅣ상의 기능이 병렬적으로 수행될수 있도록 소프트웨어를 작성한다.

주의 깊게 작성한 멀티스레드 프로그램은 일반적으로 수행 시간, 사용자 응답성, 프로그램 구조 중 적어도 하나 이상은 싱글스레드 프로그램보다 우수하다.

운영체제의 규칙에 대해서 정확히 파악하고 있어야 한다.

멀티스레드 프로그램을 사용하는 이유

1
병렬화의 증가, 빠른 처리, 향상된 안정성, 사용자에 대한 향상된 응답성, CPU 사용률의 증가

스레드를 사용하면 안되는 경우

1
DeadLock, Stravation(기아현상)을 발생시킬수 있는 요인이 있다.
  • 확실한 이유를 가지고 있지 않은 경우에는 스레드를 사용하면 안된다.( 두 개의 스레드가 명확하게 독립적이지 않은 경우에는 하나의 작업을 둘로 나누면 안된다.)

  • 스레드에 의한 작업의 빈도를 고려할 때, 운영체제가 스레드 스케쥴링을 하고 스레드를 다루는 데 발생하는 부하(Overhead)가 실제 스레드에 의해 수행된느 작업량보다 클 경우에는 스레드를 사용하면 안된다.

스케쥴러

1
2
3
4
5
6
7
Windows는 모든 스레드를 Round Robin 형태로 스케쥴링한다.

현재 스레드를 수행을 중지시키고 새로운 스레드를 수행시키기 위해서 운영체제는 문맥 교환을 한다.

각각 의 스레드 안에는 TCB(스레드 문맥 블록 : Thread Context Block)이 있고 이 안에는 PCB(Process Context Block)의 포인터가 있다.

문맥 블록은 기본적으로 스레드가 지난번에 CPU에서 동작했을때의 레지스터 값의 스냅샷을 나타낸다.

스레드 상태 전이

문맥교환

  1. 스케쥴러가 같은 프로세스 안에서 동작하는 두 개의 스레드의 문맥 교환을 하는 경우.

  2. 스케쥴러가 다른 프로세스에서 동작하는 두 개의 스레드의 문맥 교환을 하는 경우. 같은 프로세스 안에서 문맥교환 하는것보다 큰 비용이 소모된다.

스레드 상태

1
시스템의 모든 스레드는 기본적으로 세 가지 상태 중 한가지에 속한다.

Running

1
수행 중인 스레드는 컴퓨터의 CPU에서 현재 스레드의 코드가 수행되고 있는 스레드를 의미한다.

Ready

1
현재 수행 중인 스레드는 아니지만 운영체제가 CPU에게 시간을 할당하면 바로 수행 할수 있는 스레드를 준비상태에 있다고 한다.

Block / Suspended

1
세마포어 같은 커널 객체가 Signaled 되거나 I/O 동작이 끝나기를 대기하는 스레드를 블록된 상태라고 한다.Sleep이나 SuspendThread 같은 시스템 콜을 사용하게 되면 OS에 의해 잠깐동안 보류될수 있다.

최소 단위 오퍼레이션 (Atomic Operation)

  • 기본적으로는 에섬코드 1줄이라고 봐도 무방하다. (완벽하진 않음.)
  • 스레드 디버깅 = 어셈블리 코드를 보고 Sync가 깨질만한 요소를 찾아야 된다.
  • 최대한 락을 안걸고 쓰기 위해서는 디스어셈블리를 봐야됨.

상호 배제

1
2
최소 수행 단위를 넘어서는 길이의 동작을 인터럽트 되지 않고 수행해야 할 필요가 발생한다.
이럴경우 데이터 구조나 코드 영역에 대한 상호 배타적인 접근만을 허용하기 위해서 동기화 도구를 사용하게 되는데 이걸 상호 배제라고 함. (뮤텍스, 세마포어 동기화 객체를 이용해서 락을 건다.)

교착 상태

1
DeadLock 상태. 서로 자기 자원을 가지고 있고 상대방 스레드에 있는 자원을 서로 요구하면 무한적 락이 걸린다.
  1. 다수 자원에 대한 소유와 대기(Hold And Wait For Multiple Resource)

    • 적어도 하나의 스레드는 자원에 대한 소유권을 갖고 있어야 하고, 다른 스레드에 의해 소유된 공유 자원을 획득하려고 해야 함.
  2. 상호 배체(Mutual Exclusion)

    • 자원의 소유권은 반드시 한번에 하나의 스레드에게 만 허용되어야 함.
  3. 무선점(No Preemption)

    • 운영체제가 다른 스레드에게 공유 자원에 대한 소유권을 주기 위해서 이미 공유 자원을 소유하고 있는 스레드의 소유권을 뺏을수 없다.
  4. 순환 대기(Circular Wait)

    • 둘 이상의 스레드가 순환 대기를 발생하는 형태로 자원에 대해 대기하고 있어야 한다.
1
위의 4가지 조건이 동시에 모두 발생하지 않는다면, 교착상태는 발생하지 않는다.

스레드 역학

1
2
3
4
5
6
7
생성 - CreateThread(스레드 핸들 리턴)
임의적인 쓰레드 스택 크기도 지정가능 (기본 1메가)
스레드 안은 무한 루프를 통해 중지하지 않고 계속 돌리게한다.
함수 포인터를 써서 스레드를 지정.
스레드 종료 코드 가 0 -> 우리가 메인에 return 0 한것과 같음.

스레드 참조 카운터가 0이 되면 실제로 스레드가 파괴된다.
1
2
3
4
5
6
7
CreateThread(NULL, 0, 스레드 함수 주소, 함수 파라메터, 플래그, 스레드 ID(Out) )

GetCurrentHandle() // 가상 핸들을 리턴한다. (CloseHandled 되지 않으며 참조 카운트도 증가하지 않음.)

// 스레드에서 제일 좋은건 리턴시키는것. ExitThread -> Return과 같음. , TerminateThread -> 강제중단 , 메모리 해제도 안됨.

// 우리는 _beginthreadex, _endthreadex만 사용할것임. process.h를 헤더에 추가.

스레드 동기화 방법

Interlocked 계열

1
2
3
4
5
보통 Lock이라고 하지 않음. 특정 변수에 대한 안전한 동기화
인터록 계열은 동기화 객체를 사용하지 않고, 안전하게 처리해준다. ( 함수는 아니다. 바로 어셈블 코드로 넘어간다.)

뮤텍스, 세마포어, 이벤트와 동일하게 Enter하고 Leave하는 방식.
인터록 함수는 대상 변수에 대해 동일하게 인터록를 써줘야 한다.

Critical Section

1
2
3
4
5
6
7
8
9
10
11
12
13
14
상호 배제를 구현하기 위해서 임계영역을 사용한다. 임계영역은 한번에 하나의 스레드만이 들어갈수 있다.

유저 객체이기 때문에, 커널 객체인 뮤텍스, 세마포어, 이벤트 보단 빠르다.
객체 자체는 커널 객체는 아니지만, 동기화 행위는 유저 모드에서 할수 없다.

다른 스레드가 임계영역에 들어오지 않았다면, 다른 동기화모드보다 효율적이다.
= 만약 이 상황이 아니라면 인터록모드로 처리한다.

같은 스레드에 의해 재귀적으로 계속 획득할수 있다. Enter, Leave 카운팅은 정확해야 된다.

동기화가 들어가지 않는다면 카운팅밖에 없기 때문에, 거의 속도에 영향을 주지 않는다.
= 2개 이상의 스레드가 대기중이라면 누가 먼저 처리될지는 모른다.

락카운트, 해제 카운트, 락 세마포어, 점유중인 스레드 스핀 카운트로 구성되어 있다.

Spin Lock

1
2
일반적으로 Lock 거는 곳에서 CPU 자원을 소비하면서 무한 루프를 돌려 락을 요청한다.
=> 커널모드로 전환, 대기 스레드에 넣고 다시 깨어나는 Overhead의 낭비를 줄이기 위해서다. (그 만큼 CPU 사용량이 증가한다.) Lock Free 구조와 비슷하다.

WaitForSingleObject, WaitForMultipleObject

1
2
3
4
5
6
7
8
9
10
커널 동기화 객체들은 이 함수를 통해서 락을 건다. 모든 커널 객체는 Signaled, NonSignaled 상태로 나뉘어진다.

Signaled 상태라면 , 이함수를 통해 깨어난다.
NonSignaled 상태라면, 이 함수를 통해 잠든다.

프로세스 객체 Signaled = 프로그램이 종료되었을때
쓰레드 객체 Signaled = 스레드가 종료되었을때
!!이벤트 객체 Signaled = SetEvent와 PulseEvent 함수에 따라 조절된다.
뮤텍스 객체 Signaled = 뮤텍스 객체가 어떤 스레드도 가지고 있지 않을때 (스레드는 ReleaseMutex로 소유포기가능)
세마포어 - 뮤텍스와 비슷하지만 카운팅을 한다. => 몇명이 소유할수 있는가를 컨트롤한다.
1
2
3
4
5
6
7
DWORD WaitForSingleObject(HANDLE, 대기시간)// => 우리는 이벤트로 많이 쓸것. 리턴이 WAIT_OBJECT_0 이면 정상

DWORD WaitForMultipleObject(핸들 숫자, 핸들 배열 포인터, 전부 대기 Flag, 대기 시간);
// 배열의 인덱스가 앞에 있는 것 부터 순차적으로 확인한다. 즉 Signaled 빈도가 높은 스레드는 가장 뒤에 넣어준다.
// 안그러면 뒷 스레드가 처리되지 않거나 무시된다.

// Return WAIT_OBJECT_0 ~ WAIT_OBJECT_0 + nCount - 1;

Mutex

1
2
가장 일반적으로 사용되는 동기화 커널 객체
임계 영역과 달리 서로 다른 프로세스에 속한 세르드가 동일 자원에 대해서 접근하는 경우에도 동기화하는데 사용할수 있다.

동작원리

  1. 생성

    1
    2
    3
    4
    HANDLE CreateMutex(LPSECURITY_ATTRIBUTES, BOOL, LPCSTR); // 보안속성, 해당 함수 호출 스레드도 포함여부, 뮤텍스 객체 이름을 나타내는 문자열 

    // 성공 : 뮤텍스 핸들
    // 실패 : NULL
  2. 이미 생성된 뮤텍스의 핸들 확보 또는 뮤텍스가 존재하지 않은 경우 뮤텍스를 생성하지 않기를 원할때(검색)

    1
    2
    3
    4
    HANDLE OpenMutex(DWORD dwDesiredAccess, BOOL InheritHandle, LPCTSTR lpName);
    // lpName이름의 뮤텍스를 가지려는 스레드의 접근 형태를 나타내는 비트 플래그
    // 이 함수를 호출하는 프로세스가 CreateProcess 함수를 사용한 경우 핸들이 새로 생성된 프로세스에 상속 가능한지 나타냄.
    // 열려고 하는 뮤텍스의 이름을 나타냄
  3. 뮤텍스 해제

    1
    BOOL ReleaseMutex(HANDLE hMutex);

Event

스레드를 외부에서 제어(자동모드와 수동모드가 있다.)
Signaled 신호를 없앨때 이를 자동으로 할지 수동으로 할지 정하는것.
자동일 경우 작업을 끝낸뒤에 없애버린다. 또한 이경우 SetEvent, ResetEvent를 쓰지 않는게 좋다.
수동일 경우 SetEvent, ResetEvent를 무조껀 써야 한다.
-> 우리는 대부분 메뉴얼모드를 종료용으로 쓸것이다.

동작원리

  1. 생성

    1
    2
    3
    4
    HANDLE CreateEvent(보안기술, 메뉴얼리셋, 초기상태, 이름);
    // 2번재 인자 : TRUE 수동리셋 이벤트, FALSE: 자동리셋 이벤트
    // 3번째 인자 : 이벤트 객체의 초기 상태를 지정하는 플래그
    // 4번째 인자 : 이벤트 객체이름
  2. 검색

    1
    HANDLE OpenEvent(DWORD dwDesiredAccess, BOOL InHeritHandle, LPCSTR lpName);
  3. 이벤트 객체를 Signaled화

    1
    BOOL SetEvent(HANDLE hEvent);
  4. 이벤트 객체를 NonSingaled

    1
    BOOL ResetEvent(HANDLE hEvent);
  5. 이벤트 객체가 대기중이라면 시그널상태로 바꿔준다.\

    1
    BOOL PulseEvent(HANDLE hEvent);