익명 사용자
로그인하지 않음
계정 만들기
로그인
youngwiki
검색
Concurrent Programming 문서 원본 보기
youngwiki
이름공간
문서
토론
더 보기
더 보기
문서 행위
읽기
원본 보기
역사
←
Concurrent Programming
문서 편집 권한이 없습니다. 다음 이유를 확인해주세요:
요청한 명령은 다음 권한을 가진 사용자에게 제한됩니다:
사용자
.
문서의 원본을 보거나 복사할 수 있습니다.
상위 문서: [[컴퓨터 시스템]] ==개요== 컴퓨터 공학에서, 동시성(concurrent)이란 control flow들이 시간상으로 겹치는 것을 의미한다. 이는 컴퓨터 시스텀의 여러 계층에서 나타나며, OS 커널이 여러 애플리케이션을 실행하기 위해 사용하는 메커니즘 중 하나이다. 하지만 concurrent라는 개념은 애플리케이션 수준에서도 적용되어 아래와 같이 사용될 수 있다: * 느린 I/O 장치에 접근: I/O 장치는 상대적으로 느리게 실행되며, 이에 따라 CPU는 커널이 해당 작업을 수행하는 동안 다른 프로세스를 실행한다. * 사람과의 상호 작용: 컴퓨터를 사용하는 사람들은 동시에 여러 작업을 수행하기를 원하며, 이를 지원하기 위해 concurrent 개념이 사용된다. * 여러 네트워크 클라이언트 처리: [[Network Programming#Example Echo Client and Server: Iterative|Iterative server]]는 사실상 현실적이지 않은 서버이다. 이에 따라 concurrent 개념을 사용하여 각 클라이언트에 대해 별도의 control flow를 통해 처리할 수 있어야 한다. * 멀티코어 머신에서의 병렬 계산: 요즘 최신 시스템은 여러 CPU로 구성된 멀티코어 프로세서를 사용한다. 여러 control flow를 활용하는 애플리케이션은 멀티코어 프로세서를 통해 더욱 빠르게 실행될 수 있는데, 이는 각각의 flow들이 교대로 실행되는 것이 아니라 병렬적으로 실행되기 때문이다. 애플리케이션 수준의 동시성을 사용하는 애플리케이션을 concurrent program이라고 하며, 현대의 OS는 이를 만들기 위해 세 가지 접근 방식을 기본적으로 제공한다: # Process-based: 각각의 '''control flow를 커널이 스케쥴링하고 관리하는 하나의 프로세스'''로 다룬다. #* 이때 프로세스는 서로 다른 가상의 주소 공간을 가지므로, 서로 통신하기 위해서는 명시적인 프로세스 간 통신(IPC) 메커니즘을 사용해야 한다. # Event-based: 애플리케이션이 하나의 프로세스 내에서 control flow들을 명시적으로 스케쥴링하는 방식이다. #* 이때 control flow는 FSM으로 모델링되며, 파일 디스크립터에서 데이터가 도착함에 따라 메인프로그램이 상태를 전이시키며, 이를 위해서 I/O multiplexing이라는 기술을 사용한다. #* 이 방식은 하나의 프로세스로 구성되어 있기 때문에 모든 flow들이 하나의 주소 공간을 공유한다. # Thread-based: 해당 방식에서는 커널이 단일 프로세스 내에서 실행되는 thread들을 자동으로 관리한다. #* 이 방식은 process-based 방식과 같이 커널에 의해서 스케쥴링되면서, 동시에 event-based 방식과 같이 하나의 프로세스 내에서 같은 주소 공간을 공유한다는 점에서 하이브리드 방식이라고 볼 수 있다. ==Iterative Servers== [[파일:Iterative server control flow.png|대체글=Figure 1. Iterative server control flow|섬네일|Figure 1. Iterative server control flow]] Iterative 서버(server)는 여러 클라이언트의 요청을 하나씩 순차적으로 처리하는 서버이다. 이러한 서버의 control flow는 figure 1에 잘 나타나있다. Figure 1은 다음과 같은 control flow를 설명하고 있다: # Client 1이 서버에 <code>connect()</code> 함수를 호출하여 연결을 요청하고, 서버가 이를 <code>accept()</code> 한다. # Client 1이 <code>write()</code> 함수를 호출하여 데이터를 서버로 전송한다. # 서버가 <code>read()</code> 함수를 통해 전송된 데이터를 읽고, 이를 처리하여 <code>write()</code> 함수를 통해서 client 1에게 데이터를 전송한다. # Client 1은 연결을 <code>close()</code>하고, 서버는 비로소 client 2의 연결 요청을 <code>accept()</code> 한다. 이를 client 2 입장에서 control flow를 다시 살펴보면, 다음과 같다: # <code>connect()</code>: 서버는 클라이언트의 연결 요청을 listen backlog 큐에 저장하고, 이에 따라 <code>connect()</code> 함수는 즉시 반환된다. #* 클라이언트가 <code>connect()</code> 함수를 호출했을 때, 클라이언트는 SYN 패킷을 서버로 보내고, 서버는 이에 대한 응답으로 ACK 패킷을 보낸다. #* 클라이언트는 서버로부터 ACK 패킷을 받고, 최종 ACK 패킷을 다시 서버로 보내고 handshake를 완료한다. #* 이 상태에서 서버의 커널은 연결을 listen backlog 큐에 저장해두며, 서버가 나중에 <code>accept()</code> 함수를 호출했을 때 해당 큐에서 연결을 하나 꺼내와서 처리한다. #* 즉, 서버는 연결을 즉시 수락하지 않으며, 보류된 연결로서 큐에 저장한다. 다른 의미로 보면, <code>accept()</code> 함수는 이 큐에서 연결 하나를 꺼내는 함수이다. # <code>rio_writen()</code>: 데이터를 클라이언트 TCP 소켓 버퍼에 작성하고, 커널이 이를 서버로 전송힌다. #* 클라이언트의 I/O 동작은 서버의 상태와는 무관하게 동작하므로,<ref>즉, 서버가 데이터를 실제로 read() 했는지 여부는 확인하지 않는다.</ref> 해당 함수를 호출한 즉시 반환된다. # <code>rio_readlineb()</code>: 해당 함수는 서버가 연결을 실제로 accept() 하기 전까지 블로킹(blocking)된다. #* 이는 서버가 아직 client 1과 통신 중이기 때문에 client 2의 요청을 아직 처리하지 않았기<ref>서버는 아직 Client 1만 처리 중이므로, Client 2에 대해 accept()도 하지 않았고, 데이터를 읽지도 않은 상태이다.</ref> 때문이다. 이러한 관점에서 볼 때, client 2는 매우 큰 불편함을 겪는다고 볼 수 있다. 또한 client 1이 서버와의 상호작용 도중 잠시 자리를 비운다면, 해당 서버는 그 동안 어떤 작업도 수행하지 않으므로 매우 큰 비효율성이 초래된다. 즉, 이에 대한 해결책이 필요하며, 그것이 바로 concurrent server이다. ==Concurrent Programming with Processes== Concurrent 프로그래밍의 가장 단순한 방법은 fork(), exec(), waitpid()와 같은 함수들을 사용해 프로세스를 통해 구현하는 것이다. 예를 들어 concurrent 서버를 구축하는 방식 중 하나는 부모 프로세스에서 클라이언트의 연결 요청을 수락하고, 그 후에 각각의 클라이언트마다 새로운 자식 프로세스를 생성하여 서비스를 제공하는 것이다. <gallery caption="Figure 2" widths="200px" heights="200px"> Server accepts connection.png|Figure 2.1. Server accepts connection Server forks a child.png|Figure 2.2. Server forks a child Server accepts another connection.png|Figure 2.3. Server accepts another connection Server forks another child.png|Figure 2.4. Server forks another child </gallery> 이 방식이 어떻게 작동하는지 살펴보기 위해, 두 개의 클라이언트와 하나의 서버가 있고, 서버가 listening 디스크립터(예: listenfd(3))을 통해 연결 요청을 기다린다고 가정하자. 클라이언트 1이 먼저 서버에게 연결 요청을 보낸다면, 서버는 해당 요청을 수락하고 connected 디스크립터(예: connfd(4))를 반환한다. 해당 요청을 수락한 후, 서버는 자식 프로세스를 fork()하고, 해당 자식 프로세스는 서버의 [[System-Level I/O#Sharing Files|디스크립터 테이블]]의 전체 복사본을 가진다. 따라서 자식은 listenfd(3)를 닫고, 부모는 connfd(4)를 닫는다. 이때, 부모 자식의 connfd는 동일한 [[System-Level I/O#Sharing Files|파일 테이블]] 항목을 가리키므로, 부모가 자신의 connfd를 반드시 닫아야 한다. 만약 그렇지 않다면, 해당 connfd의 파일 테이블 항목은 삭제되지 않고, 결과적으로 메모리 누수(memory leak)가 발생하여, 가용 메모리를 모두 소비하고 시스템이 다운될 수 있다.<br> 부모가 클라이언트 1을 위해 자식을 생성한 이후, 클라이언트 2가 새로운 연결 요청을 보낸다고 하자. 그러면 서버는 연결 요청을 수락하고, connfd(5)를 반환한다. 이후 부모는 또 다른 자식 프로세스를 fork()하고, 이 자식 프로세스는 connfd(5)를 이용해 클라이언트 2에게 서비스를 제공한다. 이 시점에서, 부모는 다음 연결 요청을 기다리고 있으며, 두 자식은 각자의 클라이언트를 동시(concurrently)에 서비스하고 있다. 이 모든 과정들은 figure 2에 잘 나타나 있다. 이렇게 구현된 서버는 아래와 같은 특징들을 가진다: # 각 클라이언트는 자기 전용 자식 프로세스에 의해 처리된다. # 자식 프로세스들끼리는 메모리 등을 공유하지 않는다. 즉, 안전하고 구조가 명확하지만 프로세스 자원이 많이 소모된다는 단점이 있다. ===Process-Based Concurrent Echo Server=== 아래는 process-based concurrent 서버의 간단한 예시를 보여준다. 이때 아래 코드에서 나온 echo() 함수는 [[Network Programming#Example Echo Client and Server: Iterative|Network Programming]] 문서에서 설명된 코드를 그대로 사용하였다: <syntaxhighlight lang="c"> #include "csapp.h" void echo(int connfd); void sigchld_handler(int sig) { while (waitpid(-1, 0, WNOHANG) > 0); //모든 좀비 프로세스를 수거한다. return; } int main(int argc, char **argv) { int listenfd, connfd; socklen_t clientlen; struct sockaddr_storage clientaddr; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } Signal(SIGCHLD, sigchld_handler); //SIGCHLD에 대한 시그널 핸들러 등록 listenfd = Open_listenfd(argv[1]); //argv[1]에 해당하는 포트 번호로 서버용 소켓을 열고 bind, listen while (1) { clientlen = sizeof(struct sockaddr_storage); connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen); ////클라이언트가 연결 요청을 보내면 수락, connfd 생성 if (Fork() == 0) { Close(listenfd); //자식 프로세스는 listeing 디스크립터가 필요없으므로 닫는다. echo(connfd); //echo() 함수를 통해 서버와 통신한다. Close(connfd); //해당 connfd를 다 썼으므로 닫는다. exit(0); } Close(connfd); //서버는 해당 connfd가 필요없으므로 닫는다. } } </syntaxhighlight> 위 코드를 기반으로하는 서버는 몇가지 주의 사항을 가진다. 먼저 서버는 일반적으로 장기간 실행되므로, 좀비 프로세스를 수거하는 SIGCHLD 핸들러를 반드시 포함하여야 한다. 이때 리눅스의 시그널을 큐잉되지 않으므로, SIGCHLD 핸들러는 여러 좀비 프로세스들을 한번에 수거할 수 있어야 한다. 그리고, 부모와 자식은 각자의 connfd 복사본을 반드시 닫아야 한다. 특히 부모가 자신의 connfd 복사본을 닫지 않으면 메모리 누수가 생길 수 있다. 마지막으로, 소켓의 파일 테이블 내의 reference count 때문에, 부모 자식 모두의 connfd 복사본이 닫힐 때까지 클라이언트와의 연결은 종료되지 않는다. ===Pros and Cons of Process-based Servers=== Process-based 방식은 부모 프로레스와 자식 프로세스 사이에서 명확하게 정의되는 공유 모델을 가지고 있다. 파일 테이블은 공유하지만, 파일 디스크립터와 전역 변수들은 서로 공유하지 않는다. 이는 간단하고 직관적이므로 장점이라고 볼 수 있다. 또한 프로세스 마다 주소 공간이 분리되어 있다는 특징을 가지고 있는데, 이는 장점이자 단점이다. 이는 하나의 프로세스가 실수로 다른 프로세스의 가상 메모리를 덮어쓰는 것이 불가능하게 만들며, 이는 여러 오류들을 없애 준다. 하지만 주소 공간이 분리되어 있으므로, 프로세스 사이에서의 정보 공유가 더욱 어려워지며 서로 정보를 공유하기 위해서는 명시적인 IPC(interprocess communications) 메커니즘을 사용해야 한다. 또 다른 단점은, 프로세스 제어와 IPC에 추가적인 오버헤드가 발생하여 속도가 느려지는 경향이 있다는 것이다. ==Concurrent Programming with I/O Multiplexing== 사용자가 Standard I/O에 입력한 대화형 명령에도 응답할 수 있도록 해야 한다면, 서버는 동시에 아래 두가지 I/O 이벤트를 처리해야 한다: # Standard I/O을 통해 터미널에서 사용자의 입력을 받는 작업 # 네트워크 클라이언트로부터의 전송된 연결 요청을 처리하는 작업 위 둘중에서 어떤 이벤트를 우선적으로 처리해야 하는지는 확실하지 않다. 서버가 <code>accept()</code> 함수를 통해 클라이언트로부터의 연결 요청을 기다린다면, standard I/O로부터의 입력 명령어에는 응답할 수 없다. 마찬가지로 서버가 <code>read()</code> 함수를 통해서 입력 명령어를 기다리고 있다면, 네트워크 클라이언트로부터의 연결 요청을 처리할 수 없다. 이러한 딜레마에 대한 하나의 해결책은 I/O multiplexing이라 불리는 기법이다. 서버는 기본적으로 여러 디스크립터를 가지고 있다. 이때 핵심적인 아이디어는 어떤 서버에 대해 프로세스를 여러 개 만드는 대신, 하나의 프로세스가 여러 소켓을 감시하며 이벤트가 발생한 것만 처리하는 방식이다. 이는 대략적으로 아래와 같은 반복 루프를 이용한다: # connfd와 listenfd 중 어떤 파일 디스크립터에 읽을 수 있는 데이터가 준비되었는지를 확인한다. # listenfd에 데이터가 있다면, 새로운 클라이언트 연결 요청이 들어왔다는 뜻이다. #* 이 경우 <code>accept() </code> 함수를 호출하여 새 연결을 수락하고, connfd[] 배열에 새로운 connfd[]를 등록한다. # 프로세스는 connfd[] 배열을 검사하여 입력이 존재하는 것만 찾아서 처리한다. 이때 여러 파일 디스크립터에 대해서 I/O 이벤트를 감시하는 것은 <code>select(), epoll()</code> 함수들을 사용한다. 해당 함수들은 커널이 프로세스를 잠시 중단(block)시키고, 여러 파일 디스크립터에 대해 I/O 이벤트를 감시하도록 한다. 이때 하나 이상의 이벤트가 생기면 깨어나서 처리하도록 한다. 즉, I/O multiplexing을 간단하게 설명하면, '''<code>select(), epoll()</code> 함수를 사용하여 커널로 하여금 프로세스를 중지(suspend)시키고, 하나 이상의 I/O 이벤트가 발생했을 때, control을 애플리케이션으로 이전'''하는 것이다. * 디스크립터 집합 {0, 4} 중 하나가 읽을 수 있는 상태가 되면 반환된다. * 디스크립터 집합 {1, 2, 7}에 대해 하나가 쓸 수 있는 상태가 되면 반환된다. * select()는 최대 152.13초 동안 I/O 이벤트를 기다리며, 그 시간 안에 아무 이벤트도 발생하지 않으며, 타임아웃 상태가 되며, 0을 반환한다. <syntaxhighlight lang="c"> #include <sys/select.h> int select(int n, fd_set *fdset, NULL, NULL, NULL); FD_ZERO(fd_set *fdset); // fdset의 모든 비트를 0으로 초기화 FD_CLR(int fd, fd_set *fdset); // fdset에서 해당 fd 비트를 지움 FD_SET(int fd, fd_set *fdset); // fdset에서 해당 fd 비트를 켬 FD_ISSET(int fd, fd_set *fdset); // 해당 fd가 fdset에 포함되어 있는지 검사 </syntaxhighlight> 논리적으로, 디스크립터 집합은 아래와 같은 n개의 비트 벡터로 생각할 수 있다. b<sub>n−1<sub>, ..., b<sub>1</sub>, b<sub>0</sub> 각 비트 b<sub>k</sub>는 디스크립터 k에 해당하며, 디스크립터 k가 집합의 멤버인 경우에만 <code>b<sub>k</sub> = 1</code>이다. <code>select()</code> 함수가 받는 두 개의 인자는 read set이라고 불리는 디스크립터 집합과, 해당 집합의 크기<ref>해당 디스크립터 집합이 수용가능한 최대 디스크립터의 수를 의미한다.</ref>를 의미한다. <code>select()</code> 함수는 읽을 준비가 된 디스크립터가 하나 이상 생길 때까지 프로세스를 블록(block)하며, 어떤 I/O 이벤트가 발생하였을 때 I/O 작업에 대해 준비된 디스크립터의 개수를 반환한다.<ref>타임아웃이 발생하면 0을 반환하고, 오류 시에는 -1을 반환한다.</ref> 디스크립터 k가 읽을 준비가 되었다는 뜻은, 해당 디스크립터에서 1바이트를 읽으려는 요청이 블록되지 않는 경우를 의미한다.<ref>즉, <code>read()</code>와 같은 함수가 실행되었을 때 즉각적으로 데이터를 가져올 수 있는 상태를 의미한다.</ref><br> 부가적인 효과로 <code>select()</code> 함수는 인자로 전달된 fd_set을 수정하여, read set의 부분 집합인 ready set을 표시한다. 이 ready set은 실제로 읽을 준비가 된 디스크립터들로 이루어지며, 함수의 반환값은 이 ready set의 크기를 의미한다. 주의할 점은, fd_set이 변경되기 때문에 <code>select()</code> 함수를 호출할 때마다 읽기 집합을 갱신해야 한다는 것이다. ===Iterative server using I/O multiplexing=== 아래는 <code>select()</code> 함수를 사용하여 standard I/O로부터 사용자의 명령어를 받을 뿐 아니라, 클라이언트로부터의 연결도 받는 iterative echo 서버를 구현한 예시 코드이다: <syntaxhighlight lang="c"> #include "csapp.h" void echo(int connfd); void command(void); int main(int argc, char **argv) { int listenfd, connfd; socklen_t clientlen; struct sockaddr_storage clientaddr; fd_set read_set, ready_set; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } listenfd = Open_listenfd(argv[1]); //listening 소켓을 열고 bind, listen //read_set은 감시할 디스크립터 집합이며, 이는 고정되어 있다. FD_ZERO(&read_set); //빈 파일 디스크립터 집합 생성 FD_SET(STDIN_FILENO, &read_set); //표준 입력(stdin) 디스크립터 0 추가 FD_SET(listenfd, &read_set); //listening 디스크립터 3 추가 while (1) { ready_set = read_set; //원래의 read_set으로 복구한다. //accept() 함수를 호출해 연결 요청을 기다리는 대신 select() 함수를 호출하여 I/O 이벤트를 감시 Select(listenfd+1, &ready_set, NULL, NULL, NULL); if (FD_ISSET(STDIN_FILENO, &ready_set)) //Standard I/O에 명령어가 입력되었을 때 command(); //stdin으로부터 명령어를 읽는다. if (FD_ISSET(listenfd, &ready_set)) { //클라이언트로부터의 연결 요청이 들어왔을 때 clientlen = sizeof(struct sockaddr_storage); connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); echo(connfd); //connfd 디스크립터를 이용해 클라이언트와 상호 작용 Close(connfd); } } } void command(void) { //터미널에 입력된 사용자 명령어를 읽어들이는 함수이다. char buf[MAXLINE]; if (!Fgets(buf, MAXLINE, stdin)) exit(0); //EOF를 만난 경우 exit printf("%s", buf); //사용자 명령어 출력 } </syntaxhighlight> [[파일:Fd set setting by FD ZERO.png|섬네일|Figure 3. fd_set setting by FD_ZERO]] [[파일:Fd set setting by FD SET.png|섬네일|Figure 4. fd_set setting by FD_SET]] [[파일:Select() returns fd set when user types "Enter".png|섬네일|Figure 5. select() returns fd_set when user types "Enter"]] 위 코드에서는 <code>open_listenfd()</code> 함수를 사용하여 listening 디스크립터를 열고, <code>FD_ZERO()</code> 매크로 함수를 이용하여 빈 디스크립터 집합을 만든다. 그 다음의 두 줄에서는 read set에 stdin 디스크립터 0과 listening 디스크립터 3을 추가한다. 이때의 read set은 figure 3, 4와 같이 나타난다.<br> 그 이후의 반복 루프 내에서는 <code>accept()</code>를 호출해 연결 요청을 기다리는 대신, <code>select()</code> 함수를 호출하여 listening 디스크립터 또는 stdin 중 어느 하나라도 읽을 준비가 될 때까지 블록한다. 예를 들어, 사용자가 Enter 키를 눌렀다면 stdin 디스크립터가 읽을 준비가 되므로, 이때 <code>select()</code> 함수는 figure 4와 같은 준비 집합을 반환한다. <code>select()</code> 함수가 반환되며, <code>FD_ISSET()</code> 매크로 함수를 사용하여 어떤 디스크립터가 읽을 준비가 되었는지를 확인한다. stdin이 준비되었으면, <code>command()</code> 함수를 호출하여 입력을 읽고, 파싱하고, 명령에 응답한 후 다시 반복문을 진행한다. Listening 디스크립터가 준비되었다면, <code>accept()</code> 함수를 호출하여 connected 디스크립터를 얻고, 이를 바탕으로 <code>echo()</code> 함수를 통해서 클라이언트가 자신의 연결을 닫을 때까지 전송된 각 줄을 읽고 다시 돌려보낸다. 해당 프로그램은 <code>select()</code> 함수를 사용하는 좋은 예이지만, 여전히 아쉬운 점이 있다. 문제는 클라이언트와 연결되면, 클라이언트가 자신의 연결을 닫을 때까지 서버는 해당 연결에 구속된다는 것이다. 따라서 이 상태에서 stdin을 통해서 명령어를 입력하면, 클라이언트와의 연결이 종료될 때까지 해당 명령어를 실행할 수 없다. === I/O Multiplexed Event Processing=== [[파일:FSM for concurrent event-based echo server.png|가운데|섬네일|Figure 6. FSM for concurrent event-based echo server]] I/O multiplexing은 특정한 이벤트의 결과를 통해서 흐름이 진행되는 concurrent event-driven 프로그램에 대한 기본적인 기법으로 사용될 수 있다. Figure 6는 concurrent event-driven echo 서버의 논리적인 흐름(logical flow)를 나타내는 FSM이다. 서버는 새로운 클라이언트 k에 대해, 새로운 FSM을 생성하고 이를 connected 디스크립터 dk와 연관시킨다. Figure 6에서 알 수 있듯이, FSM sk는 하나의 상태(state)<ref>"디스크립터 dk가 읽기 준비가 되기를 기다리는 중”</ref>, 하나의 입력 이벤트(event)<ref>“디스크립터 dk가 읽기 준비가 됨”</ref>, 하나의 전이(transition)<ref>“디스크립터 dk로부터 한 줄의 텍스트를 읽고 echo() 하기”</ref>만을 가진다. 서버는 <code>select()</code> 함수를 사용한 I/O multiplexing을 통해서 입력 이벤트의 발생을 감지한다. 각각의 connected 디스크립터가 읽기를 준비할 때마다, 서버는 해당 상태 기계의 전이를 실행한다. 아래는 이와 같은 메커니즘을 이용한 concurrent event-based echo 서버의 예시 코드이다: <syntaxhighlight lang="c"> #include "csapp.h" typedef struct { int maxfd; //read set 배열 내에서 최대 인덱스 int maxi; //clientfd 배열 내에서 최대 인덱스 fd_set read_set; //현재 서버가 감시할 모든 디스크립터 집합 fd_set ready_set; //select()에 의해 읽기 준비된 디스크립터 집합 int nready; //select() 함수를 통해 준비가 되어 있는 디스크립터들의 수 int clientfd[FD_SETSIZE]; //연결된 클라이언트의 소켓 디스크립터들 rio_t clientrio[FD_SETSIZE]; //Rio_readlineb()을 위한 읽기 버퍼들 } pool; //활성화된 클라이언트들의 집합을 관리하는 구조체 int byte_cnt = 0; //서버가 읽어들인 총 바이트 수 int main(int argc, char **argv) { int listenfd, connfd; socklen_t clientlen; struct sockaddr_storage clientaddr; static pool pool; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } listenfd = Open_listenfd(argv[1]); //listening 소켓을 열고 bind, listen init_pool(listenfd, &pool); //pool 구조체 초기화 while (1) { //select() 호출 → 리슨 소켓 또는 클라이언트 소켓에서 읽기 가능한 상태가 올 때까지 대기 pool.ready_set = pool.read_set; pool.nready = Select(pool.maxfd+1, &pool.ready_set, NULL, NULL, NULL); //listenfd가 준비되었다면 클라이언트가 연결 요청을 한 것 if (FD_ISSET(listenfd, &pool.ready_set)) { clientlen = sizeof(struct sockaddr_storage); connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); add_client(connfd, &pool); } //연결된 클라이언트 중에서 select() 함수를 통해 준비가 감지된 소켓에서 echo() 수행 check_clients(&pool); } } </syntaxhighlight> 위 코드에서 read set 집합은 poop이라는 구조체로 관리된다. 서버는 main 함수 내에서 <code>init_pool()</code> 함수를 호출해 pool을 초기화한 후, 무한 루프에 진입한다. 해당 무한 루프 내에서 서버는 <code>select()</code> 함수를 호출하여 두 가지의 입력 이벤트를 감지한다. 하나는 새로운 클라이언트로부터 연결 요청을 받는 것이고, 다른 하나는 기존 클라이언트에 연결된 connfd가 읽기 준비가 된 경우이다. 새 클라이언트로부터 연결 요청을 감지하면 서버는 <code>Accept()</code> 함수를 호출하여 연결을 열고, <code>add_client()</code> 함수를 호출하여 클라이언트를 pool 구조체를 통해 등록한다. 마지막으로 서버는 <code>check_clients()</code> 함수를 호출해 준비된 각 connected 디스크립터로부터 한 줄의 텍스트를 echo한다. <syntaxhighlight lang="c"> void init_pool(int listenfd, pool *p) { int i; p->maxi = -1; for (i = 0; i < FD_SETSIZE; i++) //clientfd 배열의 원소 비활성화 p->clientfd[i] = -1; p->maxfd = listenfd; FD_ZERO(&p->read_set); FD_SET(listenfd, &p->read_set); //초기 read_set의 유일한 원소는 listenfd } </syntaxhighlight> <code>init_pool()</code> 함수는 pool 구조체를 초기화한다. clientfd 배열은 연결된 디스크립터의 집합을 나타내며, 정수 -1은 사용 가능한 슬롯을 뜻한다. 초기에는 clientfd 배열은 비어 있고, read_set 배열은 listenfd 디스크립터 하나를 저장한다. <syntaxhighlight lang="c"> void add_client(int connfd, pool *p) { int i; p->nready--; for (i = 0; i < FD_SETSIZE; i++) { if (p->clientfd[i] < 0) { //비어있는 clientfd 배열 항목 찾기 p->clientfd[i] = connfd; //비어있는 clientfd 배열 항목에 connfd 등록 Rio_readinitb(&p->clientrio[i], connfd); FD_SET(connfd, &p->read_set); //pool 구조체에 connfd 등록 if (connfd > p->maxfd) p->maxfd = connfd; //maxfd의 값 갱신 if (i > p->maxi) p->maxi = i; //maxi의 값 갱신 break; } } if (i == FD_SETSIZE) app_error("add_client error: Too many clients"); //더 이상 클라이언트 등록 불가 } </syntaxhighlight> <code>add_client()</code> 함수는 새로운 클라이언트와 연결된 connfd를 clientfd 배열에 추가한다. clientfd 배열에서 빈 슬롯을 찾은 후, 서버는 connfd를 배열에 추가하고, 해당 디스크립터에 대해 <code>rio_readlineb()</code> 함수 호출이 가능하도록 Rio 읽기 버퍼를 초기화한다. 그리고, <code>select()</code> 함수가 감시할 read set에 해당 디스크립터를 추가한다. <syntaxhighlight lang="c"> void check_clients(pool *p) { int i, connfd, n; char buf[MAXLINE]; rio_t rio; for (i = 0; (i <= p->maxi) && (p->nready > 0); i++) { //ready set에 속하는 원소 탐색 connfd = p->clientfd[i]; //알맞은 디스크립터 사용 rio = p->clientrio[i]; //알맞은 rio 버퍼 사용 if ((connfd > 0) && (FD_ISSET(connfd, &p->ready_set))) { //해당 디스크립터가 준비되었다면, echo함 p->nready--; if ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) { //클라이언트로부터 읽어 들이기 byte_cnt += n; printf("Server received %d (%d total) bytes on fd %d\n", n, byte_cnt, connfd); Rio_writen(connfd, buf, n); //클라이언트에 echo하기 } else { //EOF가 감지되었다면, 해당 connfd를 clientfd 배열에서 제거 Close(connfd); FD_CLR(connfd, &p->read_set); p->clientfd[i] = -1; } } } } </syntaxhighlight> <code>check_clients()</code> 함수는 준비된 각 connfd로부터 텍스트 한 줄을 echo하는 함수이다. 풀어서 설명하면, 텍스트 줄 읽기에 성공하면 해당 줄을 클라이언트에게 다시 echo하며, echo한 문장열의 길이와 지금까지 모든 클라이언트로부터 echo한 문자열들의 총 바이트 수를 출력한다. 만약 클라이언트가 연결을 닫아 EOF가 감지되면, 서버는 자신의 연결도 닫고 해당 디스크립터를 clientfd 배열에서 제거한다. Figure 6의 FSM의 관점에서 보면, <code>select()</code> 함수는 입력 이벤트를 감지하고, <code>add_client()</code> 함수는 새로운 FSM을 생성한다. <code>check_clients()</code> 함수는 상태 전이를 수행하여 입력 라인을 echo하고, 클라이언트가 연결을 닫으면 FSM을 삭제한다. ===Pros and Cons of Event-based Servers=== 위에서 작성한 서버는 I/O multiplexing 기반의 event-driven 프로그래밍의 장점과 단점을 잘 보여주는 사례이다. 첫 번째 장점은 event-based 설계가 process-based 설계보다 프로그램의 동작에 대해 더 많은 control을 프로그래머에게 부여한다는 것이다. 위 서버 코드를 응용하면 어떤 클라이언트에게 우선 서비스를 제공하는 event-based concurrent 서버를 작성할 수 있는데, 이는 process-based 서버에서는 구현하기 어렵다.<br> 또 다른 장점은 해당 서버가 하나의 control flow 내에서 실행되기 때문에 코드의 모든 부분이 그 프로세스의 전체 주소 공간에 접근할 수 있다는 것이다. 이는 각각의 flow 간에 데이터를 공유하기 쉽도록 만들어 준다. 또한 이와 같이 구현하면 gdb와 같은 디버깅 도구를 통해서 순차적인 control flow를 거치며 디버깅할 수 있다는 장접이 있다.<br> 마지막으로, 프로세스나 스레드를 제어하는데 소요되는 오버헤드가 존재하지 않아 효율적으로 구현할 수 있다는 장점이 있다. 이러한 장점 덕분에 높은 퍼포먼스를 요구하는 웹 서버나 검색 엔진등에 사용된다. 하지만 이렇게 구성된 서버는 코드가 복잡해진다는 치명적인 단점을 가지고 있다. 위의 예시 코드는 process-based 서버보다 3배 더 긴 코드를 가지고 있다. 이는 concurrent의 granularity가 낮아질수록 코드의 복잡성이 증가한다. Granularity란, 단위 시간마다 control flow가 실행하는 명령어의 수이다. 예를 들어, 이 서버에서는 한 줄의 텍스트를 읽는 동안 실행되는 명령어의 수가 concurrent의 granularity이다.<br> 또한 어떤 control flow가 클라이언트로부터의 입력을 읽고 있는 동안에는, 다른 어떤 명령어도 실행할 수 없다는 문제점을 가진다. 이러한 결점 때문에 어떤 악의적인 클라이언트가 텍스트 줄의 일부만 전송하고 정지해버릴 경우, 서버는 다른 클라이언트로부터의 연결 요청이나 echo를 수행할 수 없다.<br> 또 다른 중요한 단점은 하나의 control flow만 활용하기 때문에 이와 같이 작성된 서버는 멀티코어 프로세서의 성능을 완전히 활용할 수 없다는 것이다. ==각주== [[분류:컴퓨터 시스템]]
Concurrent Programming
문서로 돌아갑니다.
둘러보기
둘러보기
대문
최근 바뀜
임의의 문서로
미디어위키 도움말
위키 도구
위키 도구
특수 문서 목록
문서 도구
문서 도구
사용자 문서 도구
더 보기
여기를 가리키는 문서
가리키는 글의 최근 바뀜
문서 정보
문서 기록