대용량 소켓 서버 프로그래밍이라는 주제로 글 하나 올린 것이 화근이 되어서 해당 검색어를 통해서 제 블로그를 방문하시는 분들이 간혹 계시는 지라, 책임감을 느끼고 포스트를 이어 갑니다.  (이럴 틈이 없이 바쁜데 ㅠ.ㅠ)


오늘은 시리즈를 염두하면서 첫 번째 주제로 "Atomic Operation"에 대해서 이야기하고자 합니다.  "Atomic Operation"을 먼저 이야기하려는 이유는, 대용량 지원을 위해서는 멀티 스레드 프로그래밍이 필수이기 때문입니다.  그리고, 멀티 스레드 프로그래밍을 효율적으로 작성하기 위해서 가장 큰 적은 역시 "Context Switching 비용"입니다. 아!!  어려워 보이는 용어가 시작부터 난무하고 있습니다 ㅠ.ㅠ


우선, "Atomic Operation"을 쉽고 무식하게 설명하자면, "한 번에 실행 할 수 있는 명령어"라고 할 수 있습니다.


[소스 1]

var
  a, b : integer;
begin
  a := 1;
  b := a + 1;

위의 소스 코드를 보면 4: 라인의 경우 한 줄의 코드이기 때문에 한 번에 실행 될 것이라고 착각할 수도 있습니다.  하지만, 이것이 실제 CPU가 처리 할 때에는 [그림 1]에서처럼 여러 명령어를 순차적으로 실행해야 같은 결과를 얻을 수가 있습니다.



[그림 1]


결국 멀티 스레드 상황에서 해당 코드를 실행하기 위해서는 임계영역(Critical Section 등)을 이용해서 락을 걸어두고 실행을 마친 후 락을 푸는 작업으로 한 번에 하나의 스레드만이 코드를 실행 할 수 있도록 해야 합니다.  이때, 스레드가 락에 걸리고 풀리는 과정 중에 수반되는 작업량이 무시 못할 정도로 크며, 이로 인해서 성능 저하가 발생합니다.  이것을 "Context Switching 비용"이라고 합니다.


"b := a + 1;"과 같은 코드가 간단하고 대수롭지 않게 보일지 모르지만, 멀티 스레드 상황에서는 아주 중요한 코드가 됩니다.  이때문에 각 플랫폼 마다 해당 코드를 한 번에 실행 할 수 있는 장치를 마련하고 있습니다.  윈도우즈에서는 Interlokced 함수들이 그런 기능을 제공합니다.


[소스 2]

var
  Old : TObject;
  iIndex, iMod : integer;
begin
  FCurrent := AObj;

  iIndex := InterlockedIncrement(FIndex);
  if iIndex >= FRingSize then begin
    iMod := iIndex mod FRingSize;
    InterlockedCompareExchange(FIndex, iMod, iIndex);
  end else begin
    iMod := iIndex;
  end;

[소스 2]는 대용량 소켓 서버를 작성하면서 패킷 풀을 만들기 위해서 작성한 코드의 일부분입니다.  링 버퍼에 새로운 객체를 추가하기 위해서 현재 위치(FIndex)를 한 칸 앞으로 전진하고 있습니다.  얼핏 보아도 여러 줄의 코드가 실행되고 있기 때문에 임계영역을 이용하여 락을 걸어야 할 것처럼 보입니다.  하지만, Interlocked 함수들을 이용해서 락을 사용하지 않고 현재 위치를 변경하고 있습니다.


7: InterlockedIncrement를 이용해서 FIndex에 1을 더하고 결과를 iIndex에도 저장합니다.  이 동작은 한 번에 이루어지기 때문에 멀티 스레드 상황에서 안전합니다.  이후 다른 스레드가 FIndex의 값을 변경하더라도 우리는 iIndex를 참조하고 있기 때문에 우리가 얻은 값이 중간에 변경 될 염려가 없습니다.


8-9: iIndex가 링 버퍼의 크기를 넘어서게 되면, 링 버퍼 크기로 나눠서 나머지를 취해야 합니다.  (크기가 정해진 큐를 작성하는 알고리즘 참고)


10: FIndex를 계속 증가시켜면 데이터 타입의 한계로 인해서 에러가 발생합니다.  따라서, iMod 값으로 바꿔야 합니다.  하지만, 지금 FIndex를 바꾼다면 다른 스레드가 이미 바꾼 값을 다시 되돌려 놓는 등의 문제가 발생합니다.  따라서, InterlockedCompareExchange를 이용해서 FIndex의 값이 iIndex와 같다면 아직 다른 스레드가 FIndex를 변경하지 않았으므로 iMod를 대입합니다.  이 과정 역시 한 번에 실행됩니다.


이후에는 iMod를 이용해서 "FRing[iMod]"과 같이 데이터를 접근 할 수가 있습니다.  또한 이 모든 과정이 락을 걸지 않고도 멀티 스레드에서 안전하게 동작함을 확신 할 수가 있습니다.


[소스 2]에서 생각 할 수 있는 의문점은 "링 버퍼가 한 바퀴 돌았을 때, 이전 메모리 해제를 어떻게 하는 가?"입니다.  우선 실제 코드는 [소스 3]과 같으며, 전체 코드는 http://bit.ly/XWBvBJ 를 참고하시기 바랍니다.


[소스 3]

  Old := InterlockedExchangePointer(Pointer(FRing[iMod]), AObj);
  if Old <> nil then Old.Free;

1: InterlockedExchangePointer를 이용해서 한 번에 "FRing[iMod] := AObj;"를 대입하고, 원래의 값은 Old에 대입하게 됩니다.


2: 만약, Old가 nil이 아니면 객체를 파괴합니다.


그런데 이 Old를 다른 스레드가 사용하고 있으면 어떻게 될까요?  큰 일 납니다 ㅠ.ㅠ  따라서, 저는 충분히 큰 크기의 링 버퍼를 구현하여 모든 객체의 라이프 사이클이 스레드의 참조 기간보다 길다는 것을 보장합니다.  그렇게 할 수 없는 곳에서는 사용 할 수 없지만, 일반적인 패킷 교환 등에서는 그런 일이 벌어지지 않습니다.


또한, 위의 코드는 혹시라도 그러한 일이 벌어지면 "Access Violation" 에러가 발생하게 되지만, 실제 코드에서는 좀 더 보강한 방법을 통해서 컨넥션을 종료하는 것으로 구현되어 있습니다.  그토록 오랫 동안 패킷을 처리 못했다면 분명 문제가 있는 접속이기 때문입니다.  해당 코드에 대해서는 다음 시리즈를 기약해 보도록 하겠습니다 ㅡ.ㅡ;;  (실제 패킷 풀에서는 메모리를 해제하지 않고 재사용하며, 잘못 된 재사용을 막을 수 있도록 코드가 추가되어 있습니다)



Lock-Free에 대한 참고 자료









Posted by 류종택


티스토리 툴바