Simple TCP Echo Client/Server
단순한 TCP 에코 클라이언트/서버
* Echo Client/Server
1) 클라이언트는 자신의 Standard Input 장치로부터 문자열을 읽어 서버로 보낸다.
2) 서버는 Network Input으로부터 하나의 String Line을 읽고 클라이언트에게 다시 되돌려보낸다.
3) 서버로부터 전달된 Echoed Line을 받은 클라이언트는 자신의 Standard Output 장치에 쓴다.
- 클라이언트와 서버는 서로에게 데이터 송수신이 모두 가능한 Full-Duplex Connection 형태로 구성된다.
TCP Echo Server (TCP 에코 서버)
- 클라이언트로부터의 String을 Echo하는 서버이다.
- 실질적인 Echoing은 Child Process가 수행하는, Concurrent Server 형태를 취한다.
#include <stdio.h>
// printf() Function
#include <stdlib.h>
// exit() Function
#include <sys/socket.h>
// socket() Function
// bind() Function
// listen() Function
// accept() Function
// socklen_t Type
// AF_INET Constant
#include <netinet/in.h>
// sockaddr_in Structure
// INADDR_ANY Constant
#include <arpa/inet.h>
// htonl() Function
// htons() Function
#include <strings.h>
// bzero() Function
#include <unistd.h>
// fork() Function
// close() Function
// read() Function
// write() Function
#include <errno.h>
// errno Variable
// EINTR Signal
#define SERV_PORT 9877
#define LISTENQ 1024
#define MAXLINE 4096
void str_echo(int sockfd);
ssize_t writen(int fd, const void *vptr, size_t n);
void Writen(int fd, void *ptr, size_t nbytes);
void Err_exit(char* err_msg);
int main(int argc, char **argv)
{
int listenfd;
// for Listen Socket
int connfd;
// for Connect Socket
pid_t childpid;
// for Child PID
socklen_t clilen;
// Length of Client's Address
struct sockaddr_in cliaddr, servaddr;
// Address for Client and Server
void sig_chld(int);
if ( (listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
Err_exit("socket error");
bzero(&servaddr, sizeof(servaddr) );
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
// INADDR_ANY : Wildcard Address (어떠한 Interface로부터 오는 연결도 허용한다.)
servaddr.sin_port = htons(SERV_PORT);
// SERV_PORT = 9877
// Reserved Port와의 충돌을 피하기 위해 1,023보다 커야하고,
// Berkeley-Derived 구현에서의 Ephemeral Port와의 충돌을 피하기 위해 5,000보다 커야하고,
// Ephemeral Port 값의 최대값인 49,152 이하여야 한다.
// Client는 서버의 IP주소와 포트번호(SERV_PORT)를 알고 있다 가정한다.
if ( bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr) ) < 0 )
Err_exit("bind error");
if ( listen(listenfd, LISTENQ) < 0 )
Err_exit("listen error");
// listenfd 소켓이 Listening Socket으로 변환된다.
Signal(SIGCHLD, sig_chld);
// SIGCHLD에 대한 Signal Handler를 sig_chld()로 지정한다.
for ( ; ; ) {
clilen = sizeof(cliaddr);
if ( (connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen) ) < 0) {
// 서버가 클라이언트와 연결되기까지, 서버는 Blocked된다.
// Parent가 accept()에서 Block되어 있는 동안, Child가 Terminate되면,
// sig_chld()가 수행되고, accept()에서는 EINTR이 리턴된다.
if (errno == EINTR)
// Child가 Terminate되면, for Loop를 다시 시작한다.
// Child가 Terminate되어 Blocked되어 있던 Parent까지 종료시키는 것을 방지한다.
continue;
else
Err_exit("accept error");
}
if ( (childpid = fork()) == 0) {
// Child process's workspace
if ( close(listenfd) == -1 ) // Close listening socket
Err_exit("close error");
str_echo(connfd);
exit(0);
}
if ( close(connfd) == -1 ) // Parent process closes connected socker
Err_exit("close error");
}
}
void sig_chld(int signo)
{
pid_t pid;
int stat;
while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
printf("child %d terminated\n", pid);
return;
}
void str_echo(int sockfd)
{
ssize_t n;
char buf[MAXLINE];
again:
while ( (n = read(sockfd, buf, MAXLINE)) > 0 )
writen(sockfd, buf, n);
if (n < 0 && errno == EINTR)
goto again;
// EINTR 에러가 발생되면, 무시하고 while Loop를 다시 시작한다.
else if (n < 0) {
printf("str_echo: read error\n");
exit(1);
}
}
ssize_t writen(int fd, const void *vptr, size_t n)
{ /* Write "n" bytes to a descriptor. */
size_t nleft;
ssize_t nwritten;
const char *ptr;
ptr = vptr;
nleft = n;
while (nleft > 0) {
if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
if (nwritten < 0 && errno == EINTR)
nwritten = 0; /* and call write() again */
else
return(-1); /* error */
}
nleft -= nwritten;
ptr += nwritten;
}
return(n);
} /* end writen */
void Writen(int fd, void *ptr, size_t nbytes)
{
if (writen(fd, ptr, nbytes) != nbytes)
Err_exit("writen error");
}
void Err_exit(char* err_msg){
printf("%s\n", err_msg);
exit(1);
}
- Signal Handler 내부가 아닌, 서버의 for문 내에 wait()가 위치하게 될 경우,
Child가 Termination될 때 까지 Parent가 아무런 동작도 하지 않고 Block 된다.
(즉, 한 번에 하나의 클라이언트만 처리하게 된다.)
- 위 코드에서와 같이 wait()을 Signal Handler로 등록만 시켜놓음으로써 Parent가 제기능을 할 수 있다.
TCP Echo Client (TCP 에코 클라이언트)
- 클라이언트는 서버에게 String을 전송하고 다시 되돌려받는다.
#include <stdio.h>
// printf() Function
// fgets() Function
// ferror() Function
// fputs() Function
#include <stdlib.h>
// exit() Function
#include <netinet/in.h>
// sockaddr_in Structure
#include <arpa/inet.h>
// htons() Function
// inet_pton() Function
#include <sys/socket.h>
// socket() Function
// connect() Function
// AF_INET Constant
#include <sys/types.h>
// size_t, ssize_t Types
#include <unistd.h>
// read() Function
// write() Function
#include <strings.h>
// bzero() Function
#include <string.h>
// strlen() Function
#include <errno.h>
// errno Variable
// EINTR Signal
#define SERV_PORT 9877
#define MAXLINE 4096
void str_cli(FILE *fp, int sockfd);
char *Fgets(char *ptr, int n, FILE *stream);
void Fputs(const char *ptr, FILE *stream);
ssize_t writen(int fd, const void *vptr, size_t n);
void Writen(int fd, void *ptr, size_t nbytes);
static ssize_t my_read(int fd, char *ptr);
ssize_t readline(int fd, void *vptr, size_t maxlen);
ssize_t Readline(int fd, void *ptr, size_t maxlen);
void Err_exit(char* err_msg);
static int read_cnt;
static char *read_ptr;
static char read_buf[MAXLINE];
int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
Err_exit("usage: tcpcli <IPaddress>");
if ( ( sockfd = socket(AF_INET, SOCK_STREAM, 0) ) < 0 )
Err_exit("socket error");
bzero( &servaddr, sizeof(servaddr) );
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
if ( inet_pton(AF_INET, argv[1], &servaddr.sin_addr) < 1 )
Err_exit("inet_pton error");
if (connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr) ) < 0 )
Err_exit("connect error");
str_cli(stdin, sockfd);
exit(0);
}
void str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE];
char recvline[MAXLINE];
while ( Fgets(sendline, MAXLINE, fp) != NULL ) {
Writen( sockfd, sendline, strlen(sendline) );
if ( Readline(sockfd, recvline, MAXLINE) == 0 )
Err_exit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
}
char *Fgets(char *ptr, int n, FILE *stream)
{
char *rptr;
if ( (rptr = fgets(ptr, n, stream)) == NULL && ferror(stream))
Err_exit("fgets error");
return (rptr);
}
void Fputs(const char *ptr, FILE *stream)
{
if (fputs(ptr, stream) == EOF)
Err_exit("fputs error");
}
ssize_t writen(int fd, const void *vptr, size_t n)
{
size_t nleft;
ssize_t nwritten;
const char *ptr;
ptr = vptr;
nleft = n;
while (nleft > 0) {
if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
if (nwritten < 0 && errno == EINTR)
nwritten = 0; /* and call write() again */
else
return(-1); /* error */
}
nleft -= nwritten;
ptr += nwritten;
}
return(n);
}
void Writen(int fd, void *ptr, size_t nbytes)
{
if (writen(fd, ptr, nbytes) != nbytes)
Err_exit("writen error");
}
static ssize_t my_read(int fd, char *ptr)
{
if (read_cnt <= 0) {
again:
if ( (read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
if (errno == EINTR)
goto again;
return(-1);
} else if (read_cnt == 0)
return(0);
read_ptr = read_buf;
}
read_cnt--;
*ptr = *read_ptr++;
return(1);
}
ssize_t readline(int fd, void *vptr, size_t maxlen)
{
ssize_t n, rc;
char c, *ptr;
ptr = vptr;
for (n = 1; n < maxlen; n++) {
if ( (rc = my_read(fd, &c)) == 1) {
*ptr++ = c;
if (c == '\n')
break; /* newline is stored, like fgets() */
} else if (rc == 0) {
*ptr = 0;
return(n - 1); /* EOF, n - 1 bytes were read */
} else
return(-1); /* error, errno set by read() */
}
*ptr = 0; /* null terminate like fgets() */
return(n);
}
ssize_t Readline(int fd, void *ptr, size_t maxlen)
{
ssize_t n;
if ( (n = readline(fd, ptr, maxlen)) < 0)
Err_exit("readline error");
return(n);
}
// Readline은 Disk File을 읽을 때에는 유용하나, ~
// 네트워크 프로그램에서 사용하기엔 위험하다.
void Err_exit(char* err_msg){
printf("%s\n", err_msg);
exit(1);
}
Case: Normal Startup & Termination (정상적인 시작과 종료의 경우)
Step 1. 서버 실행
[-------@localhost SimpleEcho]$ gcc -o tcpserv tcpserv.c
[-------@localhost SimpleEcho]$ ls
tcpcli tcpcli.c tcpserv tcpserv.c
[-------@localhost SimpleEcho]$ tcpserv &
[1] 24681
[-------@localhost SimpleEcho]$ netstat -a
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:9877 0.0.0.0:* LISTEN
- Echo 서버가 실행되고, socket() → bind() → listen()를 거쳐, accpet()에서 Blocked된 상황이다.
(Echo 클라이언트가 실행되고, 서버와의 Connection을 요청하면, 서버의 accept()가 왼료되여 Blocked 상태가 해제될 것이다.)
※ & 기호는 Background에서 프로세스를 실행할 것을 명령하는 역할을 한다.
- Background 프로세스는 Console을 사용하지 않는 프로세스를 의미한다.
* netstat Output
Proto : Protocol
Recv-Q : Receive Queue Size
Send-Q : Send Queue Size
Local Adderss : Local Socket Address
- 해당 프로세스가 사용하고 있는 소켓의 IP주소와 포트번호
Foreign Address : Foreign Socket Address
State : 해당 Socket의 State
* 특정 포트 확인법
netstat -nap | grep <Port_Number>
netstat -nap | grep 9877
* 특정 포트를 사용하는 프로그램 확인법
lsof -i TCP:<Port_Number>
lsof -i TCP:9877
* 특정 포트를 사용하는 프로그램 죽이는 법
fuser -k -n tcp <Port_Number>
fuser -k -n tcp 9877
Step 2. 클라이언트 실행
[-------@localhost SimpleEcho]$ ls
tcpcli tcpcli.c tcpserv tcpserv.c
[-------@localhost SimpleEcho]$ tcpcli 127.0.0.1
* 127.0.0.1 : Local Loop IP Address
- Echo 클라이언트가 실행되고, socket()을 거쳐 connect()를 호출하고, 서버와의 TCP 3-Way Handshake를 수행한다.
- 3-Way Handshake가 완료되면, 서버는 accept()가 완료되어 Blocked 상태에서 해제되고,
클라이언트는 connect()가 완료된다.
- connect()를 완료한 클라이언트는 str_cli()를 실행하고, Fgets()의 fgets()에서 String이 입력되기까지 Blocked된다.
- accept()를 완료한 서버는 fork()를 호출하고, 서버의 Child가 str_echo()를 실행하고,
str_echo()의 read()에서 클라이언트로부터의 String이 입력될 때 까지 서버의 Child는 Blocked된다.
- 서버의 Parent는 다시 accept()를 호출하여 다음 클라이언트로부터의 연결 요청을 기다리며 Blocked된다.
※ 서버의 accept()는 클라이언트의 connect()가 리턴되고 약 1.5 RTT 이후에 Blocked 상태에서 해제된다.
(본 포스트 최상단 TCP Timing Diagram 참고)
- RTT (Round Trip Time) (URL)
* 같은 호스트에서 클라이언트와 서버 프로그램을 수행할 경우, 아래와 같은 결과를 확인할 수 있다.
[-------@localhost SimpleEcho]$ ls
tcpcli tcpcli.c tcpserv tcpserv.c
[-------@localhost SimpleEcho]$ tcpserv &
[1] 7046
[-------@localhost SimpleEcho]$ tcpcli 127.0.0.1 &
[2] 7068
[-------@localhost SimpleEcho]$ netstat -a
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:9877 0.0.0.0:* LISTEN // Server's Parent
tcp 0 0 localhost.localdo:54928 localhost.localdom:9877 ESTABLISHED // Client's Socket
tcp 0 0 localhost.localdom:9877 localhost.localdo:54928 ESTABLISHED // Server's Child
[-------@localhost SimpleEcho]$ ps -o pid,ppid,tty,stat,args,wchan
PID PPID TT STAT COMMAND WCHAN
6889 6888 pts/10 Ss -bash do_wait // Shell
8595 6889 pts/10 S tcpserv wait_for_connect // Server's Parent
8606 8595 pts/10 S tcpserv tcp_data_wait // Server's Child
8605 6889 pts/10 S tcpcli 127.0.0.1 read_chan // Client
// STAT = S : Sleeping
// wait_for_connect : accpet() 또는 connect()에서 Blocked 됨
// tcp_data_wait : Socket의 I/O에서 Blocked 됨
// read_chan : Terminal의 I/O에서 Blocked 됨
- localhost는 127.0.0.1를 의미한다.
Step 3. Echoing & Termination
[-------@localhost SimpleEcho]$ tcpserv &
[1] 9676
[-------@localhost SimpleEcho]$ tcpcli 127.0.0.1
Hello World // Client to Server
Hello World // Server to Client
Good Bye // Client to Server
Good Bye // Server to Client
^D // CTRL + D (EOF)
[-------@localhost SimpleEcho]$ netstat -a
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:9877 0.0.0.0:* LISTEN // Server's Parent
tcp 0 0 localhost.localdo:35466 localhost.localdom:9877 TIME_WAIT // Client's Socket
- 클라이언트가 EOF(^D)를 입력하면 fgets() NULL Pointer를 리턴하고, str_cli()는 main()에 리턴하고,
exit()가 호출되어 클라이언트는 종료된다.
- 클라이언트의 Kernel은 모든 File Descriptor를 닫고 서버에 FIN을 전송하고, 서버는 수신한 FIN에 대해 ACK로 응답한다.
(이 때, 서버측 소켓은 CLOSE_WAIT 상태로 전환되고, 클라이언트측 소켓은 FIN_WAIT_2 상태로 전환된다.)
- 서버가 FIN을 수신할 때, 서버의 Child는 read()에서 Blocked되어있다가, read()가 0을 리턴하고,
str_echo()가 main()에 리턴하고, 서버의 Child는 exit()를 호출하여 Child는 종료된다.
- 서버의 Child에 열려있던 모든 File Descriptor는 닫히고 TCP Connection Termination의 마지막 두 Segment인 ACK와 FIN이 클라이언트에게 전송된다.
- ACK와 FIM을 수신한 클라이언트는 TIME_WAIT 상태로 전환되고, 서버의 Child이 종료될 때
SIGCHLD Signal이 서버의 Parent에게 전송된다. (SIGCHLD를 전송주체는 Kernel이다.)
- SIGCHLD에 대한 별다른 대처가 없는 본 프로그램에서, Child는 Zombie(Z) 상태로 전환된다.
(Z 상태의 프로세스들은 제거할 필요가 있다.)
- 서버의 Parent는 다른 클라이언트와의 연결을 기다린다.
Case: Connection Abort before accept() Returns
- 3-Way Handshaking이 완료된 후, 클라이언트가 서버에게 RST를 전송한 경우이다.
- RST를 수신한 후, 호출된 서버측의 accept()는 에러를 리턴하며 종료된다.
- 이 때, SVR4에서는 errno에 EPROTO가 설정되며,
POSIX에서는 errno에 ECONNABORTED가 설정된다.
Case: Termination of Server Process (연결 중, 서버 프로세스가 종료된 경우)
- 클라이언트와 서버가 연결되어 Echoing을 수행하던 도중, 서버의 프로세스가 먼저 종료된 경우이다.
- 서버에서는 Child가 종료되었으므로, SIGCHLD를 발생시켜, Child에 대한 정보를 회수한다.
- 이 경우 TCP에서는, 서버가 클라이언트에게 FIN Segment를 전송하도록 규정되어 있다.
- write한 String이 Echo되어 돌아오는 것이 아닌, FIN을 전달받은 클라이언트 측 read() 함수는 0을 리턴한다.
- 서버 Child의 종료 여부를 모르는 클라이언트로부터 String을 전송받은 서버는 RST를 클라이언트에게 전송한다.
Case: Crashing of Server Host (서버 호스트에 장애가 생긴 경우)
- 클라이언트와 서버가 정상적으로 연결된 후, 서버 호스트에 장애가 생겨, 서버 호스트가 네트워크에서 Disconnect된 경우이다.
- 이 경우는 Intermediate Router의 고장으로 클라이언트에서 서버로의 Routing이 불가능해진 경우도 해당된다.
- 서버 호스트가 Disconnect된 후, 클라이언트는 writen() 함수를 통해 데이터를 서버에 보내고 readline()에서 Blocked된다.
- 일정 시간동안, 클라이언트는 서버로부터 ACK가 없는 것을 확인하고, 여러차례 Data Segment를 Retransmission한다.
- 계속해서 Retransmission을 수행해도, 클라이언트의 Data Segment에 대한 ACK가 도착하지 않으면,
클라이언트 프로세스의 errno에는 ETIMEOUT가 설정된다.
- Intermediate Router에서 "destination unreachable" ICMP Message가 도착하면,
클라이언트 프로세스의 errno에는 EHOSTUNREACH 또는 ENETUNREACH가 설정된다.
- 서버 호스트의 장애를 파악하기까지의 시간을 줄이는 방법으로, readline() 호출에 시간만료를 설정하는 방법이 있다.
- Data Segment를 보내지 않고도, 서버 호스트의 장애를 파악하기 위한 방법으로 SO_KEEPALIVE Socket을 사용하는 방법이 있다.
Case: Crashing and Rebooting of Server Host (서버 호스트에 장애가 생기고, 재부팅된 경우)
- 클라이언트와 서버 사이에서 어떤 이유에서, 서버 기계 자체가 리부팅된 경우이다.
- 클라이언트는 소켓에 write()를 요청하지만, 서버는 리부팅된 상황이므로, 서버는 클라이언트의 요청에 대해 RST를 보낸다.
- RST를 수신한 클라이언트의 Kernel은 서버와의 소켓에 RST가 수신됨을 표시한다.
- write() 중인 User 프로세스는 이 상황을 인지하지 못하고 있다가, read()를 수행하면서 RST가 수신됨을 알 수 있다.
- 즉, read()는 -1을 리턴하고, errno를 ECONNRSET으로 설정한다.
- 이 경우에 대비하여, if문 등으로 해당 소켓과의 연결을 끊는 식으로 대응하는 것이 바람직하다.
Shutdown of Server Host (서버 호스트가 중단된 경우)
- 위급 상황에서, 서버를 Shutdown하는 Procedure가 존재하므로, 어떠한 경우에도 서버를 강제적으로 끄면 안된다.
- UNIX System에서는 종료 시, init 프로세스에서 전 프로세스에게 SIGTERM Signal(Catchable Signal)을 전송하여,
일정시간(5~20초)동안 대기한 후, 실행중인 프로세스에 SIGKILL Signal(Uncatchable Signal)을 전송한다.
- SIGKILL Signal은 프로세스에게 정리를 위한 짧은 시간을 부여한다.
- 즉, 어떤 프로세스가 SIGTERM을 Catching하지 못했다면, SIGKILL에 의해 종료된다.
- 클라이언트는 서버 프로세스의 종료 여부를 select() 또는 poll() 함수를 통해 알 수 있다.
Data Type Differences Between Two Hosts (Client-Server) (두 호스트 간 데이터 타입 차이) (클라이언트-서버)
- 두 호스트의 시스템 구조 차이에 따라, 값을 해석하는 방식에 차이가 있을 수 있다.
- 흔히, 한 호스트는 Big-Endian 방식으로, 다른 호스트는 Little-Endian 방식으로 값을 해석할 경우,
이로 인한 Logic Error가 발생할 확률이 높다.
- 이를 위한 해결 방법들로는 아래와 같은 방법이 있다.
1) 모든 데이터를 String Type 으로 전송한다.
- 이 때, 두 호스트는 같은 Character Set을 가졌다고 가정한다.
- 두 호스트가 같은 Character Set을 가졌다면,
두 호스트가 각각 어떤 Data Format(Endian 방식)을 채택했는지에 무관하게 동작한다.
2) 지원되는 Data Type들에 대한 Binary Format들을 명시적으로 정의하고,
클라이언트와 서버가 이 Format을 준수하여 통신한다.
- 정의할 Data Type 내용으로는 Bit 개수, Big-Endian 방식 혹은 Little-Endian 방식인지에 대한 여부가 있다.
- 통상, RPC Package에서 이 기법을 사용한다.
- RFC 1832에서는 Sun RPC Package에서 사용하는 XDR* 표준을 설명하고 있다.
* XDR (External Data Representation)
- 주고 받는 데이터가 Binary Data인 경우, 통신하는 Host들이 준수해야 할 Data Format 표준이다.
- 통신하는 Host들은 송수신할 데이터를 XDR Object로 구성하여 송수신한다.
- 현재 프로그래밍 언어 시스템이나 웹 서비스에는 Binary Data가 자동으로 XDR Obejct로 변환되어 송수신된다.
Reference: UNIX Network Programming Volume 1, 3E: The Sockets Networking API (W. Richard Stevens, Bill Fenner, Andrew M. Rudoff 저, Addison-Wesley, 2004)