멀티플렉싱 기반의 다중 접속 서버로 가기까지

소켓이란?

소켓은 네트워크 상에서 서버와 클라이언트 두개의 프로그램이 특정 포트를 통해 양방향 통신이 가능하도록 만들어주는 추상화된 장치입니다. 메모리의 유저 공간에 존재하는 프로세스(서버, 클라이언트)는 커널 공간에 생성된 소켓을 통해 데이터를 송수신할 수 있습니다.
socket

소켓은 아래와 같이 지역(로컬) IP 주소, Port 번호와 상대방의 IP 주소와 Port 번호, 그리고 수신 버퍼송신 버퍼가 존재합니다. 서버와 클라이언트의 소켓이 서로 연결된 후, 데이터가 들어오면 수신 버퍼로 수신 데이터가 쓰이고, 반대로 데이터를 내 보낼 때는 송신 버퍼에 데이터가 쓰입니다.
socket

C언어로 간단한 서버 & 클라이언트 구현

C언어를 이용해 linux와 window에서 간단하게 소켓을 이용해 echo server와 client를 만들어 보겠습니다.
코드 한줄 한줄을 전부 해석하기 보다는 주석을 참고해 server와 client에서 어떤 순서로 소켓이 만들어지고 통신이 이루어지는지에 중점을 두어 보겠습니다.

linux

“Everything is a File”라는 말이 있습니다. linux에서는 소켓도 하나의 파일(File), 더 정확히는 파일 디스크립터(File descriptor)로 생성되어 관리됩니다. 그러므로 저 수준 파일 입출력 함수를 기반으로 소켓 기반의 데이터 송수신이 가능합니다.

파일 디스크립터(File descriptor)

  • 운영체제가 만든 파일을 구분하기 위한 일종의 숫자
  • 저 수준 파일 입출력 함수는 입출력을 목적으로 파일 디스크립터를 요구한다.
  • 저 수준 파일 입출력 함수에 소켓의 파일 디스크립터를 전달하면, 소켓을 대상으로 입출력을 진행한다.

echo_server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[]) {
// 파일 디스크립터를 위한 변수
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int str_len, i;

struct sockaddr_in serv_adr;
struct sockaddr_in clnt_adr;
socklen_t clnt_adr_sz;

if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

// 1. socket 하나를 생성한다.
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error");

memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));

// 2. socket에 IP와 Port 번호를 할당한다.
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");

// 3. server socket(listen socket)을 통해 클라이언트의 접속 요청을 대기한다.
// 5개의 수신 대기열(큐)을 생성한다.
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");

clnt_adr_sz=sizeof(clnt_adr);

for (i=0; i<5; i++) {
// 4. 클라이언트 접속 요청을 수락한다. (클라이언트와 연결된 새로운 socket이 생성된다.)
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if (clnt_sock == -1)
error_handling("accept() error");
else
printf("Connected client %d \n", i+1);

// 5. 클라이언트와 연결된 socket을 통해 데이터를 송수신한다.
while((str_len=read(clnt_sock, message, BUF_SIZE)) != 0)
write(clnt_sock, message, str_len);

close(clnt_sock);
}

close(serv_sock);
return 0;
}

void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

echo_client.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[]) {
// 파일 디스크립터를 위한 변수
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr;

if (argc != 3) {
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}

// 1. socket 하나를 생성한다.
sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1)
error_handling("socket() error");

memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));

// 2. socket을 이용해 server의 server socket(listen socket)에 연결을 요청한다.
if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error!");
else
puts("Connected...........");

while(1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);

if (!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
break;

// 3. 연결된 socket을 통해 server로부터 데이터를 송수신한다.
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE-1);
message[str_len] = 0;
printf("Message from server: %s", message);
}

close(sock);
return 0;
}

void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

결과 확인

gcc로 컴파일 후 실행하면 결과는 다음과 같습니다.
linux_simple_socket_result

window

window는 linux와 달리 파일이 아닌 별도의 소켓 구조체가 존재합니다. 별도의 소켓 구조체를 이용한 함수를 기반으로 소켓 기반의 데이터 송수신이 가능합니다.
window 코드의 결과는 위의 linux 코드의 결과와 같으므로 생략합니다.

echo_server_win.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// window socket
#include <winsock2.h>

#define BUF_SIZE 1024
void ErrorHandling(char *message);

int main(int argc, char *argv[]) {
WSADATA wsaData;
SOCKET hServSock, hClntSock;
char message[BUF_SIZE];
int strLen, i;

SOCKADDR_IN servAdr, clntAdr;
int clntAdrSize;

if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

// 1. socket 하나를 생성한다.
hServSock = socket(PF_INET, SOCK_STREAM, 0);
if (hServSock == INVALID_SOCKET)
ErrorHandling("socket() error");

memset(&servAdr, 0, sizeof(servAdr));
servAdr.sin_family = AF_INET;
servAdr.sin_addr.s_addr = htonl(INADDR_ANY);
servAdr.sin_port = htons(atoi(argv[1]));

// 2. 생성한 socket을 server socket(listen socket)으로 등록한다.
if (bind(hServSock, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
ErrorHandling("bind() error");

// 3. server socket을 통해 클라이언트의 접속 요청을 확인한다.
if (listen(hServSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error");

clntAdrSize=sizeof(clntAdr);

for (i=0; i<5; i++) {
// 4. 클라이언트 접속 요청 대기 및 허락 (클라이언트와 연결된 새로운 socket이 생성된다.)
hClntSock = accept(hServSock, (SOCKADDR*)&clntAdr, &clntAdrSize);
if (hClntSock == -1)
ErrorHandling("accept() error");
else
printf("Connected client %d \n", i+1);

// 5. 클라이언트와 연결된 socket을 통해 데이터를 송수신한다.
while((strLen=recv(hClntSock, message, BUF_SIZE, 0)) != 0)
send(hClntSock, message, strLen, 0);

closesocket(hClntSock);
}

closesocket(hServSock);
WSACleanup();
return 0;
}

void ErrorHandling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

echo_client_win.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// window socket
#include <winsock2.h>

#define BUF_SIZE 1024
void ErrorHandling(char *message);

int main(int argc, char *argv[]) {
WSADATA wsaData;
SOCKET hSocket;
char message[BUF_SIZE];
int strLen;
SOCKADDR_IN servAdr;

if (argc != 3) {
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

// 1. socket 하나를 생성한다.
hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error");

memset(&servAdr, 0, sizeof(servAdr));
servAdr.sin_family = AF_INET;
servAdr.sin_addr.s_addr = inet_addr(argv[1]);
servAdr.sin_port = htons(atoi(argv[2]));

// 2. socket을 이용해 server의 server socket(listen socket)에 연결을 요청한다.
if (connect(hSocket, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
ErrorHandling("connect() error!");
else
puts("Connected...........");

while(1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);

if (!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
break;

// 3. 연결된 socket을 통해 server로부터 데이터를 송수신한다.
send(hSocket, message, strlen(message), 0);
strLen = recv(hSocket, message, BUF_SIZE-1, 0);
message[strLen] = 0;
printf("Message from server: %s", message);
}

closesocket(hSocket);
WSACleanup();
return 0;
}

void ErrorHandling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

고찰

linux와 window의 서버 & 클라이언트 소켓 생성과 연결 과정은 다음과 같습니다.
server_client_socket

  • 서버
    • 클라이언트로부터의 연결요청도 일종의 데이터 전송입니다. 따라서 연결 요청을 받아들이기 위해서도 하나의 소켓이 필요하고, 이 소켓을 가리켜 서버소켓 또는 리스닝 소켓이라고 합니다. listen 함수의 호출은 소켓을 리스닝 소켓으로 만듭니다.
    • accept 함수의 결과로 서버소켓을 통해 클라이언트로부터의 연결요청을 받으면, 연결요청 정보를 참조하여 클라이언트 소켓과의 통신을 위한 별도의 소켓을 추가로 하나 더 생성합니다. 그리고 이렇게 생성된 소켓을 대상으로 데이터의 송수신이 진행됩니다.
  • 클라이언트
    • 소켓을 생성하고 연결 요청을 위해서 connect 함수를 호출하는 것이 전부입니다.
    • 서버의 listen 함수호출 이후에야(서버소켓이 준비된 이후) connect 함수 호출이 유효합니다.

문제점

linux_simple_socket_result
위 예제의 경우 반복적(Iterable)으로 accept 함수를 호출하면, 계속해서 클라이언트의 연결요청을 수락할 수 있습니다. 그러나, 동시에 둘 이상의 클라이언트에게 서비스를 제공할 수 있는 상태는 아닙니다. (처음 소켓 연결을 맺은 클라이언트가 종료하기 전까지는 다른 클라이언트의 연결은 listen 큐에 들어가 대기해야합니다.)

이 문제를 해결하기 위해 둘 이상의 클라이언트들이 동시에 접속해 서버로부터 서비스를 제공받을 수 있는 여러 다중 접속 서버의 구현 방법들에 대해 알아보겠습니다.

다중 접속 서버 구현 방법

  • 멀티프로세스 기반 서버 : 다수의 프로세스를 생성하는 방식으로 서비스를 제공한다.
  • 멀티스레드 기반 서버 : 클라이언트의 수만큼 스레드를 생성하는 방식으로 서비스를 제공한다.
  • 멀티플렉싱 기반 서버 : 입출력 대상을 묶어서 관리하는 방식으로 서비스를 제공한다.

멀티프로세스 기반의 다중 접속 서버

멀티프로세스 기반의 다중 접속 서버는 다수의 프로세스를 생성하는 방식으로 서비스를 제공합니다.
multi_process_server

  1. 부모 프로세스는 리스닝 소켓으로 accept 함수 호출을 통해서 연결요청을 수락합니다.
  2. 이때 얻게 되는 소켓의 파일 디스크립터(클라이언트와 연결된 연결 소켓)를 자식 프로세스를 생성해 넘겨줍니다.
  3. 자식 프로세스는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공합니다.

핵심은 연결이 하나 생성될 때마다 프로세스를 생성해서 해당 클라이언트에 대해 서비스를 제공하는 것입니다.

echo_multi_process_server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);
void read_childproc(int sig);

int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;

pid_t pid;
struct sigaction act;
socklen_t adr_sz;
int str_len, state;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);
// 1. socket 하나를 생성한다.
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));

// 2. socket에 IP와 Port 번호를 할당한다.
if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 3. 생성한 socket을 server socket(listen socket)으로 등록한다.
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");

while(1) {
adr_sz = sizeof(clnt_adr);
// 4. 부모 프로세스는 리스닝 소켓으로 accept 함수 호출을 통해서 연결요청을 수락한다.
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if (clnt_sock == -1)
continue;
else
puts("new client connected...");
// 5. 이때 얻게 되는 소켓의 파일 디스크립터(클라이언트와 연결된 연결 소켓)를 자식 프로세스를 생성해 넘겨준다.
pid = fork();
if (pid == -1) {
close(clnt_sock);
continue;
}
if (pid == 0) {
close(serv_sock);
// 6. 자식 프로세스는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.
while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len);

close(clnt_sock);
puts("client disconnected...");
return 0;
}
else
close(clnt_sock);
}
close(serv_sock);
return 0;
}

void read_childproc(int sig) {
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("removed proc id: %d \n", pid);
}
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

결과 확인

echo_result_multi_process
위에서 Iterable하게 구현했을때 발생했던 문제를 각 클라이언트 요청마다 별도의 프로세스를 생성함으로써 문제를 해결한 것을 확인할 수 있습니다.

고찰

  • 장점
    • 프로그램 흐름이 단순하기 때문에 이해하기 쉽습니다.
    • 안정적인 동작이 가능합니다. 운영체제에서 프로세스는 서로 독립된 실행 객체로 존재합니다. 서로 독립된 메모리 공간을 갖고 서로 다른 프로세스끼리 서로 영향을 미치지 않고 독립적으로 수행이 가능합니다.
  • 단점
    • 프로세스 복사에 따른 성능 문제가 있습니다.
    • 병렬 처리해야 하는 만큼의 프로세스를 생성해야 합니다.
    • fork에 의해 자식 프로세스가 생성될 경우, 부모 프로세스의 자원이 복사됩니다. (코드, 소켓을 포함한 모든 열린 파일들(파일 디스크립터)) 부모 프로세스로부터 accept되어 생성된 하나의 소켓에 대해 부모 프로세스와 자식 프로세스 모두에서 한 소켓에 대한 파일 디스크립터가 존재합니다. 따라서 두 파일 디스크립터를 모두 종료해야 해당 소켓을 제거할 수 있습니다.
    • 서로 다른 독립적인 메모리 공간을 갖기 때문에 프로세스간 정보 교환이 어렵다.

위의 단점들은 각 클라이언트의 요청마다 프로세스가 아닌 스레드를 생성함으로써 해결할 수 있습니다.
다음으로 멀티프로세스 기반의 다중 접속 서버의 단점을 개선할 수 있는 멀티스레드 기반의 다중 접속 서버에 대해 알아보겠습니다.

멀티스레드 기반의 다중 접속 서버

멀티스레드 기반의 다중 접속 서버는 다수의 스레드를 생성하는 방식으로 서비스를 제공합니다.
multi_thread_server

  1. 메인 스레드는 리스닝 소켓으로 accept 함수 호출을 통해서 연결요청을 수락합니다.
  2. 이때 얻게 되는 소켓의 파일 디스크립터(클라이언트와 연결된 연결 소켓)를 별도의 워커 스레드를 생성해 넘겨줍니다.
  3. 워커 스레드는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공합니다.

핵심은 연결이 하나 생성될 때마다 프로세스가 아닌 스레드를 생성해서 해당 클라이언트에 대해 서비스를 제공하는 것입니다.

echo_multi_thread_server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>

#define BUF_SIZE 30

void * handle_clnt(void * arg);
void error_handling(char * msg);

int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;

pthread_t t_id;
socklen_t adr_sz;
int str_len, state;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

// 1. socket 하나를 생성한다.
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));

// 2. socket에 IP와 Port 번호를 할당한다.
if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 3. 생성한 socket을 server socket(listen socket)으로 등록한다.
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");

while(1) {
adr_sz = sizeof(clnt_adr);
// 4. 메인 스레드는 리스닝 소켓으로 accept 함수 호출을 통해서 연결요청을 수락한다.
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);

if (clnt_sock == -1)
continue;

puts("new client connected...");

// 5. 클라이언트와 연결된 소켓의 파일 디스크립터를 워커 스레드를 생성해 넘겨준다.
pthread_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock);
pthread_detach(t_id);
}

close(serv_sock);
return 0;
}

void * handle_clnt(void * arg) {
int clnt_sock=*((int*)arg);
int str_len=0, i;
char buf[BUF_SIZE];

// 6. 워커 스레드는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.
while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len);

close(clnt_sock);
return NULL;
}

void error_handling(char * msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}

결과 확인

echo_result_multi_thread
처음 Iterable하게 구현했을때 발생했던 문제를 각 클라이언트 요청마다 별도의 스레드를 생성함으로써 문제를 해결했으며, 클라이언트의 요청마다 각 프로세스를 할당해서 해결한 방법보다 스레드를 생성해 할당함으로써 리소스 소모를 줄였습니다.

고찰

  • 장점
    • 프로세스 복사에 따른 비용보다 스레드 생성에 대한 비용이 적다.
    • 스레드간 서로 공유하는 메모리를 갖기 때문에, 스레드간 정보 교환이 쉽다.
  • 단점
    • 하나의 프로세스 내의 다수의 스레드가 존재하기 때문에 하나의 스레드에서 문제가 생긴다면 프로세스에 영향을 미쳐 나머지 다수의 스레드에도 영향을 끼칠 수 있다.

각 클라이언트 요청마다 별도의 스레드를 생성함으로써 프로세스를 생성하던 방법보다 리소스의 비용을 줄일 수 있었고, 스레드들이 서로 공유하는 메모리를 가질 수 있는 환경이 되었습니다.
그러나 I/O 멀티플렉싱(multiplexing) 기법을 사용한다면, 각 클라이언트 마다 별도의 스레드를 생성하는 것이 아닌 하나의 스레드에서 다수의 클라이언트에 연결된 소켓(파일 디스크립터)을 괸리하고 소켓에 이벤트(read/write)가 발생할 경우에만 별도의 스레드를 만들어 해당 이벤트를 처리하도록 구현할 수 있습니다.

멀티플렉싱 기반의 다중 접속 서버

입출력 다중화란 하나의 프로세스 혹은 스레드에서 입력과 출력을 모두 다룰 수 있는 기술을 말합니다. 커널(kernel)에서는 하나의 스레드가 여러 개의 소켓(파일)을 핸들링 할 수 있는 select, poll, epoll과 같은 시스템 콜(system call)을 제공하고 있습니다.

한개의 프로세스 혹은 스레드에서 한개의 클라이언트에 대한 입출력만 처리할 수 있었던 이유는 입출력 함수가 봉쇄(block)되었기 때문에, 입출력 데이터가 준비될때까지 무한정 봉쇄되어 여러 클라이언트의 입출력을 처리할 수 없었기 때문입니다.

그러나 I/O 멀티플렉싱 기법을 사용하면 입출력 다중화에서도 입출력 함수는 여전히 봉쇄로 작동하지만, 입출력 함수를 호출하기전에 어떤 파일에서 입출력이 준비가 되었는지 확인할 수가 있습니다.

봉쇄 (block)

봉쇄를 이해하기 위해 먼저 두가지 짚고 넘어가야할 사항이 있습니다.

  1. 애플리케이션에서 I/O 작업을 하는 경우, 스레드는 데이터 준비가 완료될 때까지 대기합니다. 예를 들어 소켓을 통해 read(recvfrom)를 수행하는 경우 데이터가 네트워크를 통해 도착하는 것을 기다립니다. 패킷이 네트워크를 통해 도착하면 커널 내의 버퍼에 복사됩니다. (처음에 커널 공간에 생성된 소켓의 구조에서 송신 버퍼와 수신 버퍼가 있는 것을 보았습니다.)
  2. 커널 내의 버퍼에 복사된 데이터를 애플리케이션에서 사용하기 위해서는 커널 버퍼(kernel space)에서 유저 버퍼(user space)로 복사 후 이용해야 합니다. 애플리케이션은 유버 모드에서 유저 버퍼에만 접근이 가능하기 때문입니다.

Blocking I/O Model
blocking_io_model
프로세스(스레드)는 하나의 소켓에 대해 recvfrom을 호출하고 데이터가 kernel space 도착해 user space의 프로세스 버퍼에 복사 될 때까지 시스템 호출이 반환되지 않습니다. 즉 recvfrom은 kernel space에 데이터가 도착하길 기다리는것 부터 시작됩니다. 프로세스는 recvfrom을 호출할 때부터 반환 할 때까지 전체 프로세스가 봉쇄됩니다.

I/O Multiplexing Model
multiplexing_io_model
멀티플렉싱 모델에서는 select 함수를 호출해, 여러개의 소켓들 중 recvfrom이 가능한 소켓이 생길 때까지 대기합니다. select의 결과로 recvfrom을 호출할 수 있는 소켓의 목록이 반환되면, 해당 소켓들에 대해 recvfrom을 호출합니다.

봉쇄 모델(Blocking I/O model)에서는 하나의 프로세스(스레드)에서 하나의 소켓(파일 디스크립터)에 대해 recvfrom을 호출해 데이터가 kernel space에 도착했는지 확인하고 현재 읽을 수 있는 데이터가 없다면 봉쇄되어 대기했다면, 멀티플렉싱 모델(I/O Multiplexing Model)에서는 하나 이상의 소켓(파일 디스크립터)이 준비 될 때까지 대기할 수 있습니다.

select

select 방식은 이벤트(입력|출력|에러) 별로 감시할 파일들을 fd_set 이라는 파일 상태 테이블(fd 비트 배열)에 등록하고, 등록된 파일(파일 디스크립터)에 어떠한 이벤트가 발생했을 경우 fd_set을 확인하는 방식으로 동작합니다.
select_model
예를 들어 위와 같이 6개의 파일을 다루어야 한다고 했을 때, 6개의 파일에 대해 입출력 데이터가 준비될 때까지 이벤트를 기다리는 파일 상태 테이블을 준비합니다. 그 후 6개의 파일 중 입출력이 준비된 파일에 대해서 이벤트가 발생하면 이벤트가 발생한 파일 디스크립터의 수를 반환합니다. 이후 이벤트가 준비된 파일에 대해 입출력을 수행하는데 이미 데이터가 준비된 파일에 대해 입출력을 수행하기 때문에 봉쇄가 발생하지 않을 것이라는게 보장됩니다.

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)

  • nfds: 검사 대상이 되는 파일 디스크립터의 수
  • readfs: 읽기 이벤트를 검사할 파일 디스크립터의 목록
  • writefds: 쓰기 이벤트를 검사할 파일 디스크립터의 목록
  • exceptfds: 예외 이벤트를 검사할 파일 디스크립터의 목록
  • timeout: 이벤트를 기다릴 시간 제한
  • 반환 값: 이벤트가 발생한 파일의 갯수

반환 값이 이벤트가 발생한 파일의 디스크립터 목록이 아닌 파일의 갯수임에 주의해야합니다.

echo_select_server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>

#define BUF_SIZE 100
void error_handling(char *buf);

int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
struct timeval timeout;
// 파일 상태 테이블 선언
fd_set reads, cpy_reads;

socklen_t adr_sz;
int fd_max, str_len, fd_num, i;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));

if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");

FD_ZERO(&reads); // fd_set 테이블을 초기화한다.
FD_SET(serv_sock, &reads); // 서버 소켓(리스닝 소켓)의 이벤트 검사를 위해 fd_set 테이블에 추가한다.
fd_max = serv_sock;

while(1) {
cpy_reads = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 5000;

// result
// -1: 오류 발생
// 0: 타임 아웃
// 1 이상 : 등록된 파일 디스크립터에 해당 이벤트가 발생하면 이벤트가 발생한 파일 디스크립터의 수를 반환한다.
if ((fd_num = select(fd_max+1, &cpy_reads, 0, 0, &timeout)) == -1)
break;

if (fd_num == 0)
continue;

for (i=0; i<fd_max+1; i++) {
if (FD_ISSET(i, &cpy_reads)) { // fd_set 테이블을 검사한다.
// 서버 소켓(리스닝 소켓)에 이벤트(연결 요청) 발생
if (i == serv_sock) { // connection request!
adr_sz = sizeof(clnt_adr);
clnt_sock= accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
FD_SET(clnt_sock, &reads); // fd_set 테이블에 클라이언트 소켓 디스크립터를 추가한다.
if (fd_max < clnt_sock)
fd_max = clnt_sock;
printf("connected client: %d \n", clnt_sock);
}
// 클라이언트와 연결된 소켓에 이벤트 발생
else { // read message!
str_len = read(i, buf, BUF_SIZE);
if (str_len == 0) { // close request!
FD_CLR(i, &reads); // fd_set 테이블에서 파일 디스크립터를 삭제한다.
close(i);
printf("closed client: %d \n", i);
} else {
write(i, buf, str_len); // echo!
}
}
}
}
}

close(serv_sock);
return 0;
}

void error_handling(char *buf) {
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}

결과 확인

echo_result_select

고찰

  • 장점
    • 단일 프로세스(스레드)에서 여러 파일의 입출력 처리가 가능합니다.
    • 지원 하는 OS가 많아 이식성이 좋습니다. (POSIX 표준)
  • 단점
    • 커널에 의해서 완성되는 기능이 아닌, 순수하게 함수에 의해 완성되는 기능이다.
    • select 함수의 호출을 통해서 전달된 정보는 커널에 등록되지 않은 것이며, 그래서 select 함수를 호출할 때마다 매번 관련 정보를 전달해야 합니다.
    • select 함수의 호출 결과가 이벤트가 발생한 파일 디스크립터의 개수이기 때문에 어떤 파일 디스크립터에서 이벤트가 발생했는지 확인하기 위해서는 fd_set 테이블 전체를 검사해야 합니다. (속도가 느립니다)
    • 검사할 수 있는 fd 개수에 제한이 있습니다. (최대 1024개)
    • select 호출 때마다 데이터를 복사해야합니다. (select 함수를 호출한 후 이벤트를 처리할 때 fd_set 테이블 변경이 필요하기 때문에 미리 복사가 필요합니다)

POSIX란?

POSIX(Portable Operating System Interface)는 이식 가능 운영 체제 인터페이스의 약자로, 서로 다른 UNIX OS의 공통 API를 정리하여 이식성이 높은 유닉스 응용 프로그램을 개발하기 위한 목적으로 IEEE가 책정한 애플리케이션 인터페이스 규격입니다.

poll

poll도 select와 마찬가지로 멀티플렉싱을 구현하기 위한 방법입니다. poll이 여러 개의 파일을 다루는 방법은 select와 마찬가지로 fd(파일 디스크립터)의 이벤트를 기다리다가 이벤트가 발생하면, poll에서의 block이 해제되고, 다음 루틴에서 어떤 fd에 이벤트가 발생했는지 검사하는 방식을 사용합니다.

poll의 동작 원리는 select와 비슷하므로 생략합니다. 간단히 select와 비교해 차이점에 대해서만 알아보겠습니다.

  • 장점
    • select와 단일 프로세스(스레드)에서 여러 파일의 입출력 처리가 가능합니다.
    • select 방식처럼 표준 입력|출력|에러을 따로 감시할 필요가 없습니다.
    • select는 timeval이라는 구조체를 사용해 타임아웃 값을 세팅하지만, poll은 별다른 구조체 없이 타임아웃 기능을 지원합니다.
  • 단점
    • 일부 unix 시스템에서는 poll을 지원하지 않습니다.

epoll

epoll은 select 함수의 단점 극복을 위해 커널 레벨멀티플렉싱을 지원해줍니다. 커널에 관찰대상에 대한 정보를 한 번만 전달하고, 관찰대상의 범위, 또는 내용에 변경이 있을 때만 변경 사항을 알려줍니다. 리눅스에서는 epoll, 윈도우에서는 IOCP, 맥에서는 Kqueue가 이에 해당합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int epoll_create(int size); //size는 epoll_fd의 크기정보를 전달한다.
// 반환 값 : 실패 시 -1, 일반적으로 epoll_fd의 값을 리턴

int epoll_ctl(int epoll_fd, // epoll_fd
int operate_enum, // 어떤 변경을 할지 결정하는 enum값
int enroll_fd, // 등록할 fd
struct epoll_event* event // 관찰 대상의 관찰 이벤트 유형
);
// 반환 값 : 실패 시 -1, 성공시 0

int epoll_wait(int epoll_fd, // epoll_fd
struct epoll_event* event, // event 버퍼의 주소
int maxevents, // 버퍼에 들어갈 수 있는 구조체 최대 개수
int timeout // select의 timeout과 동일 단위는 1/1000
);
// 성공시 이벤트 발생한 파일 디스크립터 개수 반환, 실패시 -1 반환
  • epoll_create : epoll 파일 디스크립터 저장소 생성
  • epoll_ctl : 저장소에 파일 디스크립터 등록 및 삭제
  • epoll_wait : select 함수와 마찬가지로 파일 디스크립터의 변화를 대기한다.

epoll_create를 통해 생성된 epoll 인스턴스에 관찰대상을 저장 및 삭제하는 함수가 epoll_ctl이고, epoll 인스턴스에 등록된 파일 디스크립터를 대상으로 이벤트의 발생 유무를 확인하는 함수가 epoll_wait이다.

echo_epoll_server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
// 리눅스에서만 사용 가능
#include <sys/epoll.h>

#define BUF_SIZE 100
#define EPOLL_SIZE 50
void error_handling(char *buf);

int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];

struct epoll_event *ep_events;
struct epoll_event event;
int epfd, event_cnt;

if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");

// 커널이 관리하는 epoll 인스턴스라 불리는 파일 디스크립터의 저장소 생성
// 성공 시 epoll 파일 디스크립터, 실패시 -1 반환
epfd = epoll_create(EPOLL_SIZE);
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);

event.events = EPOLLIN;
event.data.fd = serv_sock;
// 파일 디스크립터(serv_sock)를 epoll 인스턴스에 등록한다. (관찰대상의 관찰 이벤트 유형은 EPOLLIN)
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

while(1) {
// 성공 시 이벤트가 발생한 파일 디스크립터이ㅡ 수, 실패 시 -1 반환
// 두 번째 인자로 전달된 주소의 메모리 공간에 이벤트 발생한 파일 디스크립터에 대한 정보가 들어있다.
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
puts("epoll_wait() error");
break;
}

for (i=0; i<event_cnt; i++) {
if (ep_events[i].data.fd == serv_sock) {
adr_sz = sizeof(clnt_adr);
clnt_sock= accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
event.events = EPOLLIN;
event.data.fd = clnt_sock;
// 파일 디스크립터(clnt_sock)를 epoll 인스턴스에 등록한다. (관찰대상의 관찰 이벤트 유형은 EPOLLIN)
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connected client: %d \n", clnt_sock);
} else {
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if (str_len == 0) { // close request!
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d \n", ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len); // echo!
}
}
}
}

close(serv_sock);
close(epfd);
return 0;
}

void error_handling(char *buf) {
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}

결과 확인

echo_result_epoll

고찰

  • 장점
    • 상태변화의 확인을 위한, 전체 파일 디스크립터를 대상으로 하는 반복문이 필요 없습니다.
    • select 함수에 대응하는 epoll_wait 함수호출 시, 커널에서 상태정보를 유지하기 때문에 관찰대상의 정보를 매번 전달할 필요가 없습니다.
  • 단점
    • 리눅스의 select 기반 서버를 윈도우의 select 기반 서버로 변경하는 것은 간단하나, 리눅스의 epoll 기반의 서버를 윈도우의 IOCP 기반으로 변경하는 것은 select를 이용하는 것보다 번거롭습니다.

참고