[OS] CSAPP 12: Concurrent Programming(1)

Binsu·2021년 9월 27일
0

OS

목록 보기
6/7

Concurrent Programming is Hard!

  • Race condition
  • Deadlock
  • Livelock / Starvation / Fairness

Iterative Servers

위 그림의 프로세스를 설명하면 아래와 같다.

한 번에 한 번의 일을 처리하는 Iterative Server의 경우, 소켓으로 Client 1이 Server에 연결을 요청하면 서버는 listen 하고 있다가 accept를 해준다. Client가 소켓에 무언가를 write하면 Server는 read하고 결과를 Client 1에 write한다. 그러면 Client는 기다리고 있다가 Server의 write를 read한다. 여기서 Server에 close 신호를 주면 연결은 끊어진다.

Iterative Server는 Client 1과 Server 사이의 통신 과정 사이에 connect 요청을 하더라도 Server는 Client 1에 대한 서비스가 다 끝날 때까지 accept를 못한다.

때문에, Client 2는 Client 1 때문에 서버가 응답하길 하염없이 기다리고 있어야 한다(Fairness).

open_clientfd는 이전 그림에서 Client가 socket을 만들고 Server에 connect 요청을 하는 과정에 속한다.
Connect가 됐다 하더라도 rio_writen으로 Server에 write 요청을 하고 Server가 다시 Client에 write를 해주기 전까지 Client(rio_readlineb)는 계속 기다리고 있어야 한다.

  • TCP listen backlog : Client가 Server에 connect 요청중인 큐 정보

위 그림의 상황은 Client 1이 Server에 요청을 한 뒤 유휴상태가 되어 Server에게 아무런 요청을 하고 있지 않아서 Server가 Client 1을 기다리고 있으므로 Client 2가 피해를 보고 있는 상황이다. 이런 상황을 해결하기 위해 concurrent servers가 나왔다.

  • at the same time : 물리적인 시간으로 동일한 것이 아닌 스케줄링 기법을 활용해 논리적으로 동일한 시간을 만듦

Approaches for Writing Concurrent Servers

Fork() 요약

출처 : https://dad-rock.tistory.com/395

fork() 함수는 유닉스의 System Call로써, Parent Process에서 호출하여 Child Process를 생성할 때 사용한다. 자기 자신의 fd(file descriptor)를 Child Process에 공유한다.

1. Process-based Servers

많은 메모리와 고성능의 CPU가 요구된다. 다수의 클라이언트가 동시에 접속하는 게임 서버에 적용할 경우 굉장히 비효율적이다.

  1. listenfdconnfd는 각각 listen 소켓과 connection 소켓이다.

  2. Signal()은 프로세스 시스널이다. SIGCHLD라는 시그널이 들어오면 sigchld_handler를 호출해서 실행한다.

  3. SIGCHLD라는 시그널을 Child가 보내는 것이다.

  4. Open_listenfd()로 서버 쪽에서는 소켓 만드는 것부터 시작해서 bind, listen을 한다. argv[1]은 실행할 때 파라미터(옵션)를 준 것이다.

  5. 결과적으로, 서버는 listen을 하고 있다가 클라이언트로부터 커넥션(connfd)이 오면 Accept()를 하고 Accept 쪽에서 Fork()를 통해 새로운 Child Process가 만들어진다.

  6. Parent 쪽에서 계속 listen을 해야 하므로, Child 쪽에서는 더이상 listening 소켓이 필요 없어서 Close(listenfd)를 해준다. 대신, Accept()를 통해 만든 커넥션 소켓(connfd)은 가지고 있어야 하므로 echo(connfd)를 해준다. 그 이후 Close(connfd)로 닫아준다.

  7. if문 이후 Child가 일을 다 끝내고 종료하면 SIGCHLD라고 하는 시그널이 발생해서 Parent가 이 시그널을 받는다(sigchld_handler).

  8. sigchld_handler는 wait(waitpid)해주며 좀비 프로세스가 되지 않도록 종료된 클라이언트를 거둬들인다.

위 그림에서 listenfd(3)에서 숫자는 클라이언트로부터 요청을 받아서(at listening socket) 큐에 넣을 수 있는 한도를 의미한다.
그림 맨 아래에서 연결되지 않은 검은색 점의 Server는 리스닝을 위한 소켓이 여전히 열려있다는 의미이다.

위 그림은 클라이언트로부터 Connection request를 Listening server가 받아서 fork한 뒤 각각의 Client를 담당하는 독립적인 프로세스를 만들고 실질적인 데이터를 주고받는 소켓을 만들어 서비스를 해주는 상황이다.
Parent process가 가지고 있던 listenfdconnfd도 copy되어 그대로 물려받는다.
하지만, 메인 서버에서 리스닝 소켓이 열려있어야만 계속 들어오는 커넥션 요청을 받을 수 있으므로, listenfd를 그대로 가지고 있고 child에서는 리스닝 소켓(listenfd)을 전부 닫아줘야 한다. 반대로, Parent process는 Client와 데이터를 주고받는 목적의 커넥션 소켓(connfd)을 닫아준다.
Parent와 Child process는 각각 독립된 프로세스이므로 address space는 공유되지 않는다.

sigchld_handler에서 하는 일은 종료된 Child가 사용하고 있던 리소스들을 깔끔하게 정리해주는 역할이다. 만약 정리하지 않으면 Child는 죽었음에도 그 리소스들이 점유된 채로 다른 프로세스의 자리를 차지하게 된다.
(ex. malloc() 사용 후 free()하지 않은 경우)

  • Listening 서버 프로세스는 Child 프로세스가 좀비되지 않도록 잘 reap해야 한다.

  • Parent 프로세스는 리스닝만 하고 있기 때문에 connfd는 close해야 한다. 대신 fork를 통해서 Child process를 만들게 되면, Child process도 커넥션 소켓을 물려받아 사용하게 되므로 refcnt는 2가 된다(Parent 및 Child process 모두 동일한 커넥션 소켓을 참조).

  • Child에서만 종료하더라도 Client와 Child의 커넥션은 종료되지 않는다.(refcnt(connfd)가 0이 되어야 함)

  • 때문에 fork한 뒤 refcnt가 2가 되는 것이 아니라 1이 유지되도록 소켓을 미리 종료해야 한다.(위 단락 내용과 동일)

  • 프로세스 사이(Parent-Child)에서 데이터 공유가 힘들다.

2. Event-based Servers

클라이언트들이 소켓으로 연결되어 있을 때 각각의 커넥션에 read/write 요청이 있는지 여부를 확인한다.

위 그림은 서버가 클라이언트와 연결되어 있을 때, 각각의 클라이언트와 연결된 소켓이 array로 관리되는 형태를 나타낸다.

  • 하나의 프로세스 내에서 관리하기 때문에 유지보수하기 좋으며, 디버깅하기 쉽다. 프로세스나 쓰레드를 만들어 사용하는 것이 아니므로 오버헤드(과부하) 또한 발생하지 않는다.

  • 특정한 목적에 맞게 설계하면 최고의 성능을 보여주나, 확장성 등의 측면에서 바라봤을 때 general하게 사용하기에는 무리가 있다. 구현하는 방법도 복잡하여 성능이 프로그래머의 역량에 의존된다.

  • Process-based Servers가 OS의 도움을 받아 동시성을 지원한다면, Event-based Servers는 순전히 프로그래머의 능력으로 동시성을 구현해야 한다.

  • 하나의 프로세스에서 관리하므로, 멀티코어를 사용하는 프로세서는 이점이 전혀 없다.

3. Thread-based Servers

프로세스의 전통적인 관점에서 우측 Code, data, stack이 정의되면 프로세스로 볼 수 있는데, CPU가 이 프로세스를 할당받아서 처음부터 끝까지 실행할 수도 있지만 대부분은 스케줄링 되면서 그 때 그 때 레지스터 포인터, 스택 포인터, PC 값을 운영체제 커널이 관리하는 영역에 저장한다. 이것을 context라고 한다.
CPU가 그 프로세스를 실행한다는 것은 이 context를 유지한다는 뜻이고 프로세스를 완전히 끝마치지 못하고 다른 프로세스로 switching하게 될 경우, 사용 중이던 프로세스의 context를 운영체제의 관리 영역에 저장한다. CPU가 다른 프로세스를 할당하고 다시 돌아와서 그 프로세스를 실행한다고 하면 다시 context를 불러와서 이전에 멈췄던 부분부터 작업을 이어가는 것이다. 이것이 전통적으로 바라보는 프로세스의 관점이다.
이런 context switching을 통해서 추상머신 개념을 만들 수 있는 것이다.

프로세스를 바라보는 또 다른 관점은, 위 그림과 같이 프로세스를 쓰레드와 나머지(code, data, and kernel)로 보는 것이다. 쓰레드라는 것은 사실 프로세스에 있는 function 중 하나이다. 그런데 쓰레드라는 개념으로 재탄생하여 스케줄링의 기본 단위가 된 것이다.
하나의 프로세스가 CPU에 할당이 될 때 멀티쓰레딩이 아닌 경우에는 프로세스 하나가 단일 쓰레드로 보는 것이고, 할당된 시간만큼 프로세스가 실행되고 switching이 되는 것이다.
멀티쓰레드 즉, 프로세스 내에 있는 어떤 function이 쓰레드화 하면 CPU가 그 프로세스에 할당돼있는 시간을 쪼개서 쓰레드끼리 나눠서 스케줄링하게 된다.
그렇게 되면 쓰레드들도 스케줄링을 해야 하므로 쓰레드의 context가 존재하게 된다. 때문에 원래 프로세스라 했던 것이 메인 쓰레드가 되고, 프로세스 내에 있는 함수들로부터 만들어진 쓰레드가 Child 쓰레드(실제로는 없는 용어)가 된다.
그래서 각 쓰레드는 함수로부터 받기 때문에 별도의 stack이 존재한다. 함수가 호출되면 그 함수별로 stack frame이 만들어지면서 stack이 잡힌다. 이 쓰레드별로 stack이 잡히고 그 안에 로컬 변수들이 만들어진다. 그 외에는 프로세스와 비슷하다.
정리하면, 쓰레드가 여러 개 있으면 프로세스처럼 쓰레드들 사이에서 CPU 타임이 번갈아가며 스케줄링이 되는 것이다.(context switching)

프로세스 간 context switching 시간보다 쓰레드 간 context switching 시간이 훨씬 작다.(overhead가 크게 없음)

멀티플 쓰레드로 프로그램이 실행되면 하나의 프로세스 내에 여러 개의 스케줄링 단위가 존재하는 것이고, 각각의 쓰레드는 하나의 프로세스를 공유하기 때문에 그 프로세스 안의 data, code 등의 context를 공유한다.
그러나 그림과 같이 쓰레드는 하나의 function으로부터 만들어진 것이다. 예시로 Thread 1은 main thread로부터 만들어진 것이다.
Thread 1과 Thread 2는 각각의 stack을 가지고 있지만 서로 로컬에 접근할 수 없는 것은 아니다.

  • 프로세스도 쓰레드와 마찬가지로 쓰레드 아이디를 가진다.

위 그림에서 왼쪽의 도식은 하나의 프로세스가 여러 개의 쓰레드로 나뉘어 있는 경우이다. 좌우 도식 모두 멀티태스킹이 되는 것은 같으나, 하나의 프로세스 내의 쓰레드들이 스케줄링이 되는 것이다.
오른쪽 도식은 프로세스가 fork되어 여러 개의 프로세스들이 독립적으로 프로세스끼리 스케줄링되는 것이다.

참고자료

0개의 댓글