网络编程笔记(二)-TCP客户/服务器示例

网络编程笔记(二)-TCP客户/服务器示例

参考《UNIX网络编程》第 5 章,《TCP/IP 网络编程》 第 10 章。

回射(echo)客户/服务器原理概述

image

并发服务器端实现模型和方法:

  1. 多进程服务器:通过创建多个进程提供服务。
  2. 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务(select 和 epoll)。
  3. 多线程服务器:通过生成与客户端等量的线程提供服务。

这里学习第一种——多进程服务器。

需要用到的 linux 命令:

  1. ps au :查看进程 ID 和状态。
  2. ./可执行文件 &:后台运行某个进程。

原始并发服务器的实现:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/socket.h>

#define BUF_SIZE 100
#define LISTENQ 5
void error_handling(char *message);
void str_echo(int sockfd);

int main(int argc, char **argv)
{
	int listenfd, connfd;
	pid_t childpid;
	socklen_t clilen;
	struct sockaddr_in cliaddr, servaddr;

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

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

	if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1)
		error_handling("bind() error");

	if (listen(listenfd, LISTENQ) == -1)
		error_handling("listen() error");

	for (;;)
	{
		clilen = sizeof(cliaddr);
		connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
		if (connfd == -1)
			continue;
		else
			printf("new client %d... \n", connfd);

		if ((childpid = fork()) == 0)
		{					  /* child process */
			close(listenfd);  /* close listening socket */
			str_echo(connfd); /* process the request */
			// close(connfd);
			printf("%d client disconnected...", connfd);
			exit(0);
		}
		else if (childpid == -1)
		{
			close(connfd);
			puts("fail to fork");
	    	continue;
		}

		close(connfd); /* parent closes connected socket */
	}
}

void str_echo(int sockfd)
{
	ssize_t n;
	char buf[BUF_SIZE];

	while ((n = read(sockfd, buf, BUF_SIZE)) > 0)
		write(sockfd, buf, n);
}

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

客户端:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/socket.h>

#define BUF_SIZE 100
void error_handling(char *sendline);
void str_cli(FILE *fp, int sockfd);

int main(int argc, char **argv)
{
    int sockfd;
    struct sockaddr_in serv_addr;

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

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1)
    {
        error_handling("socket() error");
    }

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

    if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
        error_handling("connect() error\r\n");
    else
        printf("Connected....\n");

    str_cli(stdin, sockfd); /* do it all */

    close(sockfd);
    exit(0);
}

void str_cli(FILE *fp, int sockfd)
{
    int str_len = 0;
    while (1)
    {
        char sendline[BUF_SIZE], recvline[BUF_SIZE];
        printf("Input sendline(Q to quit):\n");
        fgets(sendline, BUF_SIZE, stdin);
        if (!strcmp(sendline, "q\n") || !strcmp(sendline, "Q\n"))
            break;

        write(sockfd, sendline, strlen(sendline));
        str_len = read(sockfd, sendline, BUF_SIZE - 1);
        sendline[str_len] = 0;

        printf("sendline from server : %s \n", sendline);
    }
}

void error_handling(char *sendline)
{
    fputs(sendline, stderr);
    fputs("\n", stderr);
    exit(1);
}

POSIX 信号处理

信号的定义

信号(signal)就是告知某个进程发生了某个事件的通知;信号通常是异步发生的,也就是说接受信号的进程不知道信号的准确发生时刻。

信号可以:

  1. 一个进程发给另一个进程;
  2. 内核发给某个进程。

信号的处置

每个信号都有一个与之关联的处置,即收到特定信号时的处理方法;可以通过调用 sigaction 函数来设定一个信号的处置。

处置方法有三种选择:

  1. 提供一个函数,只要有特定信号发生它就被调用。这样的函数称为信号处理函数(signal handler),这种行为称为捕获(catching)信号。有两个信号 SIGKILL 和 SIGSTOP 不能被捕获。信号处理函数由信号值这个单一的整数参数来调用,且没有返回值,其函数原型如下:

    void handler(int signo);
    
  2. 可以把某个信号的处置方法设定为 SIG_IGN 来忽略(ignore)它。SIDKILL 和 SIDSTOP 这两个信号不能被忽略;

  3. 可以把某个信号的处置方法设定为 SIG_DEF 来启用它的默认(default)处置,默认初值通常是收到信号后终止进程。另有个别信号的默认处置为忽略,如 SIGCHLD 和 SIGURG。

第一种处置方法

建立信号处置的 POSIX 方法就是调用 sigaction 函数,但比较复杂(简单方法是调用自带的 signal 函数)。POSIX 明确规定了调用 sigaction 时的语义定义。解决方法是定义自己 signal——只是调用 sigaction 函数,以所期望的 POSIX 语义提供一个简单的接口。

image

UNIX 系统自带的 signal 函数,历史悠久,不太稳定,也叫信号注册函数

#include <signal.h>  

// 功能:返回之前注册的函数指针。
// 参数:int signo,void (*func)(int)
// 返回类型:参数为int型,返回为void型函数指针
void (*signal(int signo, void (*func)(int)))(int);  

一些常见的信号值:

  • SIGALARM:已到通过 alarm 函数注册的时间
  • SIGINT:输入 CTRL + C
  • SIGCHILID:子进程终止

利用 sigaction 函数进行信号处理,可以代替 signal,也更加稳定(POSIX 明确规定了调用 sigaction 时的信号语义)。signal 函数在 UNIX 的不同系列操作系统中可能存在区别,但是 sigaction 完全相同

#include <signal.h>  

/*
参数:
	signo:传递的信号  
	act:对应于第一个参数的信号处理函数  
	oldact:获取之前注册的信号处理函数指针,若不需要则传递0
返回值:成功返回0,失败返回-1
*/  
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);  

声明并初始化结构体以调用上述函数:

struct sigaction {  
	void (* sa_handler)(int);		// 保存信号处理函数的指针
	sigset_t sa_mask;  				// 可初始化为0
	int sa_flags;  					// 可初始化为0
};

第二种处置方法

把某个信号的处置方法设定为 SIG_IGN 来忽略(ignore)它。SIDKILL 和 SIDSTOP 这两个信号不能被忽略。

第三种处置方法

可以把某个信号的处置方法设定为 SIG_DEF 来启用它的默认(default)处置,默认处置通常是收到信号后终止进程。有个别信号的默认处置为忽略,如 SIGCHLD 和 SIGURG。

处理 SIGCHLD 信号(僵死进程)

僵死进程的概念

进程 ID:创建时进程都会从操作系统获得进程 ID,其值为大于 2 的整数(1 为分配给操作系统启动后的首个进程)。

通过 fork 函数创建进程:复制正在运行的、调用 fork 函数的进程,父子进程拥有完全独立的内存结构。两个进程都执行 fork 函数以后的语句,共享同一代码

初始服务器的代码存在僵死进程问题。

僵死进程:目的是为了维护子进程的信息(进程ID,终止状态,资源利用信息),以便父进程在以后某个时候存取。如果父进程未主动要求获得子进程的结束状态值,操作系统将让子进程长时间处于僵死状态。僵死进程占用内存中的空间,最终可能导致耗尽内核资源。

启动初始服务器:

image

启动初始客户端并连接服务器,可以看到,断开连接后出现僵死进程(Z):

image

销毁僵死进程的方法

wait 函数

利用 wait 函数销毁僵死进程的原理:父进程主动请求获取子进程的返回值。

#include <sys/wait.h>  

// 返回值:成功时返回终止的子进程ID,失败返回-1 
pid_t wait(int * statloc);

wait 和 waitpid 均返回两个值:已终止子进程的进程 ID 号,以及通过 statloc 指针返回的子进程终止状态(一个整数)。子进程终止状态需要通过下列宏分离:

  • WIFEXITED:子进程正常终止时返回 TRUE。

  • WEXITSTATUS:返回子进程的返回值。

wait(&status);
if (WIFEXITED(status)){  
	printf("Child pass num : %d", WEXITSTATUS(status));  
}

waitpid 函数

wait 的局限性:调用 wait 函数时,如果没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止。wait 函数不能处理客户端与服务器同时建立多个连接的情况(《UNIX 网络编程》P109-111)

wait 函数会引起程序阻塞,但 waitpid 函数不会阻塞,而且可以指定等待的目标子进程,options 指定为 WNOHANG 时没有终止子进程也不会阻塞。

#include <sys/wait.h>  

/*
参数:
	pid:等待终止的目标子进程ID,若传递-1,则与wait函数相同,等待任意子进程  
	statloc:与wait函数的statloc参数一致
	options:传递头文件sys/wait.h中声明的常量 WNOHANG,即使没有终止子进程也不会阻塞,而是返回0并退出函数
*/
// 返回值:成功时返回终止子进程ID,失败返回-1 
pid_t waitpid(pid_t pid, int * statloc, int options);

// Example:
while (!waitpid(-1, &status, WNOHANG)){  
	sleep(1);  
	puts("sleep 1sec.");  
}  
if (WIFEXITED(status)){  
	printf("child send %d \n",WEXITSTATUS(status));
}

使用 signal 消除僵死进程

  1. 在服务器程序中调用 listen 之后添加信号注册函数 signal:

    signal(SIGCHLD, sig_chld);
    
  2. 编写信号处理函数 sig_chld:

    // 版本1:使用 wait
    void sig_chld(int signo){
        pid_t pid;
        int   stat;
        pid = wait(&stat);
        printf("child %d terminated\n",pid);
        return;
    }
    
    // 版本2,使用 waitpid
    void sig_chld(int signo){
        pid_t pid;
        int   stat;
        while( (pid = waitpid(-1, &stat, WHOHANG)) > 0 )
        	printf("child %d terminated\n",pid);
        return;
    }
    

可以看到,没有僵死进程:

image

使用 sigaction 消除僵死进程

类似 signal,在服务器程序中调用 listen 之后添加以下代码,使用同样的信号处理函数 sig_chlid。

// 处理僵死进程
	struct sigaction act;
	int state;
	act.sa_handler = sig_chld;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    state = sigaction(SIGCHLD, &act, 0);

使用 waitpid 而不是 wait 的原因——UNIX 信号不排队

image

一个客户与并发服务器建立 5 个连接时,建立一个信号处理函数并在其中调用的 wait 不足以防止出现僵死进程(只能终止一个进程)。原因:所有 5 个信号都在信号处理函数执行之前产生,而信号处理函数只执行了一次,因为 Unix 信号一般是不排队的。正确的解决方法是调用 waitpid 而不是 wait:在一个循环内调用 waitpid,以获取所有已终止子进程的状态。WHOHANG 告知 waitpid 没有已终止子进程时也不要阻塞。

void sig_chld(int signo){
    pid_t pid;
    int   stat;
    while( (pid = waitpid(-1, &stat, WHOHANG)) > 0 )
    	printf("child %d terminated\n",pid);
    return;
}

小结

  1. 当 fork 子进程时,必须捕获 SIGCHLD 信号。
  2. 当捕获信号时,必须处理被中断的系统调用。(P107)
  3. SIGCHLD 的信号处理函数必须正确编写,应使用 waitpid 函数以免留下僵死进程。

服务器进程终止

复习 TCP 四次握手关闭连接的过程:

image

模拟服务器进程崩溃时,客户端会发生什么:

  1. 找到服务器子进程的进程 ID,并执行 kill 命令杀死它。此时被杀死的服务器子进程的所有打开着的描述符都将关闭。这就导致服务器向客户端发送一个 FIN,而客户端会向服务器响应一个 ACK。是四次握手关闭连接的前半部分。

  2. SIGCHLD 信号被发送给服务器父进程,僵死子进程得到正确处理。然而问题是客户进程此时阻塞在 fgets 函数上,等待从终端接收一行文本。

  3. 此时在另一个窗口运行 netstat 命令,可以看到 TCP 连接终止序列的前半部分已经完成。

    服务器终端:

    [qhn@Tommy tcpcliserv]$ ps au
    USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
    root       1910  0.1  0.3 341764  7732 tty1     Ssl+ 19:26   0:04 /usr/bin/X :0 -background none -noreset -audit 4 -verbose -auth /run
    qhn        3840  0.0  0.1 117096  2772 pts/0    Ss   19:44   0:00 /usr/bin/bash
    qhn        6772  0.0  0.0   6388   544 pts/0    S    20:30   0:00 ./tcpserv04
    qhn        7240  0.0  0.1 116968  3184 pts/1    Ss   20:32   0:00 /usr/bin/bash
    qhn        8104  0.0  0.0   6396   396 pts/1    S+   20:39   0:00 ./tcpcli01 127.0.0.1
    qhn        8105  0.0  0.0   6388   104 pts/0    S    20:39   0:00 ./tcpserv04
    qhn        8159  0.0  0.0 155448  1872 pts/0    R+   20:39   0:00 ps au
    [qhn@Tommy tcpcliserv]$ kill 8105
    [qhn@Tommy tcpcliserv]$ child 8105 terminated
    
    [qhn@Tommy tcpcliserv]$ netstat -a | grep 9877
    tcp        0      0 0.0.0.0:9877            0.0.0.0:*               LISTEN     
    tcp        0      0 localhost:9877          localhost:51616         FIN_WAIT2  
    tcp        1      0 localhost:51616         localhost:9877          CLOSE_WAIT 
    
  4. 此时,在客户端键入一行文本 "another line",str_cli 调用 written,客户 TCP 将数据发送给服务器(FIN 的接收并没有告知客户 TCP 服务器进程已经终止,但实际上在本例中服务器进程已经被杀死了)。由于服务器先前打开的连接套接字已经终止,于是响应以一个 RST。

  5. 客户进程之前阻塞在 fgets 上,看不到这个 RST。客户发送 "another line" 后立即调用 readline,直接收到终止符 EOF(因为之前客户端收到了 FIN),这是客户未预期的,所以客户端会提示以出错信息 "server terminated prematurely"(服务器过早终止)退出。

    客户端终端:

    [qhn@Tommy tcpcliserv]$ ./tcpcli01 127.0.0.1
    hello
    hello
    hi
    hi
    another line
    str_cli: server terminated prematurely
    [qhn@Tommy tcpcliserv]$ 
    

    本例的问题在于:当 FIN 到达客户套接字时,客户正阻塞在 fgets 调用上,不能够及时处理。客户端实际上在应对两个描述符——套接字和用户输入。它不能单纯阻塞在这两个源中某个特定源的输入上,而是应该同时阻塞在这两个源的输入上。这正是 selectpoll 这两个函数的目的之一。

服务器主机崩溃

在不同的主机上运行服务器和客户端,先启动服务器,再启动客户端,确定它们正常启动后,从网络上断开服务器主机,并在客户键入一行文本。

  1. 当服务器主机崩溃后(不是由操作员执行命令关机),已有的网络连接上不再发出任何东西。
  2. 此时客户键入一行文本,文本由 writen 写入内核,再由客户 TCP 作为一个数据分节发出。然后客户阻塞在 readline 调用,等待服务器回射应答。
  3. 此时用 tcpdump 就会发现,客户 TCP 持续重传数据分节,试图从服务器上接收一个 ACK。
  4. 既然客户阻塞在 readline 调用上,该调用会返回一个错误:
    • 假设服务器已经崩溃,对客户的数据分节根本没有响应,返回错误 ETIMEDOUT
    • 如果某个中间路由器判定服务器已不可达,则该路由器会响应一个 “destination unreachable” (目的地不可达)ICMP 消息,返回错误为 **EHOSTUNREACH **或 ENETUNREACH

本例的问题在于:想要知道服务器主机是否崩溃,只能通过客户向服务器主机发送数据来检验。如果想不发送数据就检测出服务器主机是否崩溃,需要使用 SO_KEEPALIVE 套接字选项。

服务器主机崩溃并重启

服务器主机崩溃并重启时,在客户上键入一行文本。重启后,服务器 TCP 丢失了崩溃前所有连接信息,因此 TCP 对客户响应一个 RST(重置连接)。当客户 TCP 收到该 RST 时,客户正阻塞于 readline 调用,导致该调用返回 ECONNRESET 错误。

服务器主机关机

服务器主机被操作员关机将会发生什么:Unix 系统关机时,init 进程会给所有进程发送一个 SIGTERM 信号(该信号可被捕获),等待一段固定时间(5~12s),然后给所有仍在运行的程序发送给一个 SIGKILL(该信号不能被捕获)。这么做的目的是,留出一小段时间给所有运行的进程来清除与终止。

如果不捕获 SIGTERM 信号并终止,服务器将由 SIGKILL 信号终止。当服务器子进程终止时,它的所有打开着的描述符都被关闭,这样又回到了服务器进程终止的问题。

数据格式

在客户与服务器之间传递二进制值时,如果字节序不一样或所支持的长整数的大小不一致,将会出错。

3 个问题:

  1. 不同的实现以不同的格式存储二进制数——大端字节序与小端字节序。
  2. 不同的实现在存储相同的 C 数据类型上可能存在差异——大多数 32 位 Unix 系统使用 32 位表示长整数,而 64 位系统一般使用 64 位表示长整数。
  3. 不同的实现给结构打包的方式存在差异。因此,穿越套接字传送二进制结构绝不是明智的。

解决方法:

  1. 把所有的数值数据作为文本串来传递
  2. 显式定义所支持数据类型的二进制格式(位数、大端或小端字节序),并以这样的格式在客户与服务器之间传递所有数据。远程过程调用(RPC)通常使用这种技术。

总结

从简单的 echo 服务器开始,解决了以下问题:

  • 处理僵死子进程——采用信号处理(signal,sigaction)。
  • 服务器进程终止时,客户进程收到 FIN 但并不知道终止——使用 select、poll。
  • 服务器主机崩溃时,必须通过客户向服务器发送数据才能检验—— SO_KEEPALIVE 套接字选项。
  • 穿越套接字传送二进制结构绝不是明智的——把所有的数值数据作为文本串来传递。

参考资料

https://www.cnblogs.com/soldierback/p/10690783.html

https://blog.csdn.net/zzxiaozhao/article/details/102662861

https://wuhlan3.gitee.io/wuhlan3/2021/08/03/UNIX网络编程(五)

posted @ 2021-10-04 16:31  CoolGin  阅读(164)  评论(0编辑  收藏  举报