동시라고 말하면 같은 시간에 함께 실행될 것 같은 느낌이 들지만 번역과정에서 우리말 '동시'에서 느껴지는 느낌때문인지는 모르겠으나 computer science 에서 말하는 동시성(concurrent)은 같은 시간에 함께 실행되는 것을 의미하지 않는다.
(국어사전을 찾아보았다)
같은 때라고 하면 틀리지만 같은 시기라고 하면 얼추 맞을 수도 있겠다.
그럼 정확히 Computer Science에서 말하는 동시성(concurrent)란 특정 프로세스의 실행 시간이 다른 프로세스의 흐름과 겹치는 상황에서 동시에 실행한다고 말한다.
위 이미지에서 프로세스 A와 B, A와 C는 concurrent하게 수행되고 있지만 B와 C는 concurrent하게 수행되고 있지 않다. 교차되어 겹쳐져서 실행되지 않기 때문이다.
프로세스는 바이너리 파일로 변환되어 PC가 가르키는 주소의 instruction을 cpu가 수행하며 실행되는데, 실행 도중
os가 context switching을 시키면서 프로세스간 흐름이 겹쳐 실행되고 이를 concurrent하게 수행된다고 말한다.
위 게시글에서 짯던 웹서버의 경우 동시성 처리가 되어 있지 않다.
위 게시글에서는 클라이언트가 한 명인 1:1 상황을 다루고 있었는데 위 웹서버에서 클라이언트가 여러명이 접속하면 어떤 문제가 생길까?
위 서버에서는 listen 상태로 클라이언트의 connect를 기다리고 있고, 클라이언트가 connect 되면 유저와 연결된 fd를 생성해 해당 파일에 read/write를 하고 있는데, 여기서 다른 추가적인 클라이언트가 있다면 특정 한 클라이언트는 서버가 다른 클라이언트와 관련된 instruction들을 수행하고 있다면 무작정 기다려야하는 상황이 발생한다.
따라서 해당 상황에서 여러 클라이언트들을 동시에 service할 수 있도록 하는 방법을 살펴보자.
동시성을 수행하는 방법에는 크게 세 가지가 있다.
동시성 프로그램을 만드는 가장 간단한 방법이다. fork() 함수를 이용하여 프로세스를 생성하여 동시처리를 해주는 것 이다.
그림에서 보는 것과 같이 부모 프로세스는 listen상태로 클라이언트의 connect request를 accept하고,
유저가 connect 되된 이후 fd가 생성되고 client와 transaction이 오가는 부분은 fork 후 자식프로세스가 이를 수행하게 한다. 이 경우 주의점은 fork 후 동일한 프로세스가 생성되는 것이기에 자식프로세스는 해당 클라이언트와 관련된 작업만 수행하도록, listen 상태로 다른 클라이언트의 connection request를 accept하는 것은 부모만 할 수 있도록 자식 process의 listenfd는 닫아주어야한다. 반대로 부모 프로세스에서 connfd 는 자식 프로세스만 접근하도록 부모프로세스의 connfd를 닫아주어야한다.
또한 client와 transaction이 끝날 경우에는 자식프로세스를 kill하고 좀비 자식으로 남아있지 않도록 관리하여야한다.
1 #include "csapp.h"
2 void echo(int connfd);
3
4 void sigchld_handler(int sig)
5 {
6 while (waitpid(-1, 0, WNOHANG) > 0)
7 ;
8 return;
9 }
10
11 int main(int argc, char **argv)
12 {
13 int listenfd, connfd;
14 socklen_t clientlen;
15 struct sockaddr_storage clientaddr;
16
17 if (argc != 2) {
18 fprintf(stderr, "usage: %s <port>\n", argv[0]);
19 exit(0);
20 }
21
22 Signal(SIGCHLD, sigchld_handler);
23 listenfd = Open_listenfd(argv[1]);
24 while (1) {
25 clientlen = sizeof(struct sockaddr_storage);
26 connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
27 if (Fork() == 0) {
28 Close(listenfd); /* Child closes its listening socket */
29 echo(connfd); /* Child services client */
30 Close(connfd); /* Child closes connection with client */
31 exit(0); /* Child exits */
32 }
33 Close(connfd); /* Parent closes connected socket (important!) */
34 }
35 }
멀티 프로세스로 구현되는 동시성의 경우 간단하고 직관적이지만 프로세스 간 가상메모리를 별개로 갖기 때문에 공유하는 자원이 없어 프로세스간 데이터를 주고 받을 수 없는데, 이 것이 실행하는 프로그램에 따라 장점 혹은 단점이 되기도 한다.
프로세스간 데이터를 주고 받으려면 IPC(interprocess communication)을 이용해야하는데 이를 위한 오버헤드가 발생하게 된다.
또한, 멀티 스레드 방식에 비해 context-swiching 비용이 크다. (공간적 locality, TLB Flush 때문에)
멀티 프로세스가 부모가 자식을 데려와 여러 사람이 동시성을 처리한 방식이라면 이 방식은 사람은 한 사람인데 달인처럼 빠르게 왔다갔다 처리를 통해 동시성을 처리한다.
멀티 프로세스 방식은 os가 context switching을 해주면서 자식프로세스만 생성하면 되는 것에 비해 이 방식은
프로그래머가 multiple logical flow를 계산하고 코드를 직접 짜줘야 한다. 때문에 확장성이나 안전성에서 어려움이 있다. 다른 방식은 os가 scheduling해주는 것에 비해 프로그래머가 스케줄링까지 고려해줘야한다. 또한 한 프로세스에서 한 스레드가 실행하는 것을 전제하기에 멀티 코어의 장점을 살리지 못한다.
장점은 context switching 비용이 발생하지 않는 다는 것 이고(overhead가 가장 적게 발생), 프로그래머가 직접 코드를 프로세스 방식에 비해 짜줘야하는 수고로움 때문에 확장성이나 안전성의 문제는 있지만 반대로 프로그래머가 더 잘 제어할 수 있고 디버깅 툴을 이용해 순차적으로 프로그램이 돌아가는 것 처럼 디버깅이 가능하다고 한다.
스레드는 생성하면 커널에 의해서 자동으로 스케줄되기에 스레드를 생성해서 멀티프로세스 방식으로 했던 클라이언트 처리를 자식프로세스가 아닌 스레드를 생성하여 처리한다.
멀티프로세스에서는 유저가 100명이 접속하면 프로세스가 100개 더 생겨나게 된다(ㅎㄷㄷ..) 그에 비해 스레드는 한 프로세스 내에서 돌아가기 때문에 효율적이다. (context-switching 에 소요되는 부분도 적다)
단점은 한 스레드가 다른 스레드에 접근하는걸 막지 못한다는 것, 디버깅이 어렵다고 한다. 또한 한 스레드로 인해 프로세스에 문제가 생기면 동시에 처리되고 있는 전체 스레드들에게도 에러가 발생한다.
또한 공유자원으로 인한 문제가 발생하는데 이는 이 글의 마지막에 언급하겠다.
스레드는 프로세스의 작업을 수행하는 하나의 단위인데 스택포인터, 스택, 프로그램카운터, 고유의 ID 값을 갖고 스케줄링 되어 작업을 수행한다. 멀티 프로세스 방식과는 달리 스레드들 간에는 힙, 코드, 데이터 영역을 공유하기에 스레드간 자원 공유가 가능하다. 주의해야할 점은 스레드들 간에 논리적으로는 각각의 스택영역을 갖고 분리되어 있지만 같은 가상메모리 영역을 공유하기에 특정 스레드가 다른 스레드의 스택을 가리키는 주소값으로 접근하면 접근이 되기에 프로세스와 공유되고 있는 전체 스레드에 문제를 발생 시킬 수 있다.
크롬의 경우 우리가 열고 있는 탭에 문제가 발생해도 각 탭이 멀티프로세스 방식이기에 해당 탭만 문제가 발생하지만 인터넷 익스플로어의 경우 각 탭이 멀티 스레드기반으로 돌아가고 있어 한 탭에서 문제가 발생하면 다른 탭들도 한번에 에러가 발생하고 꺼지는 경우를 확인할 수 있다.
코드를 보자면 웹 서버의 메인 스레드가 listenfd 를 생성하고 listen 상태에서 클라이언트 요청이 오게 되면
스레드를 생성하고 confd를 해당 스레드에게 전달한다. 그럼 해당 스레드가 수행할 작업과 confd를 전달받아 스레드가 client의 transaction을 담당하게 된다.
동시성으로 인해 발생할 수 있는 문제점은 Deadlock, Starvation 등이 있고 이를 위해서 race condition이나 semaphore, lock 등의 개념들을 얘기해야하는데 이는 다음 글에 정리해보도록 하겠다.