UNIX网络编程socket

1. 基本概念

1.1 头文件

数据结构/函数 头文件
struct sockaddr_in netinet/in.h
struct sockaddr sys/socket.h
socklen_t unistd.h
htons()/htonl()/ntohs()/ntohl() netinet/in.h
inet_pton()/inet_ntop() arpa/inet.h

1.2 多进程并发服务器

服务器父进程调用accept从已完成连接队列头返回下一个已完成连接的已连接套接字(connected socket)描述符connfd,然后调用fork创建一个子进程来服务该连接客户。

  1. 为什么子进程能够服务已连接客户
    父进程fork之前打开的所有文件描述符在fork返回之后由子进程共享,所以父进程调用socket创建的监听套接字(listening socket)描述符listenfd,和该已连接套接字描述符connfd就在父进程与子进程之间共享。通常情况下,子进程读写这个已连接套接字connfd,父进程关闭这个已连接套接字;父进程再次调用accept使用监听套接字listenfd,子进程则关闭该监听套接字
  2. 为什么父进程对connfd调用close没有终止它与客户端的连接
    对于一个TCP套接字调用close会导致发送一个FIN分节,accpet返回之后父进程fork一个子进程服务已连接的客户,父进程close已连接套接字connfd,为什么没有导致子进程与客户端连接的终止?同样的,子进程close监听套接字listenfd,为什么没有导致父进程从LISTEN状态转变为CLOSED状态?
    这是因为每个文件或套接字都有一个引用计数,在文件表中维护,它是当前打开着的引用该文件或套接字的描述符的个数。调用close只是将该引用计数减1,该文件或套接字真正的清理和释放要等到其引用计数值到达0时才发生。

2. 回射服务器

2.1 多进程并发服务器,不处理服务器子进程退出

客户fgets阻塞读stdin,然后writen写入到sockfd;服务器fork子进程read阻塞读connfd,然后writen回射给客户;客户readline阻塞读sockfd,然后fputs写入stdout。
服务器子进程退出时,会给父进程发送一个SIGCHLD信号。由于服务器父进程要一直调用accept,接受客户连接,所以不能调用wait等待子进程退出。所以如果服务器父进程不处理SIGCHLD信号,子进程就进入僵尸状态。

2.2 多进程并发服务器,处理SIGCHLD信号

  1. 信号管理:

    1. 基本信号管理
      C标准定义的signal函数,早于POSIX标准,不同的实现提供不同的信号语义以达成向后兼容,不符合POSIX语义。

      #include <signal.h>
      
      typedef void (*sighandler_t)(int);
      sighandler_t signal(int signo, sighandler_t *handler);
    2. 高级信号管理
      POSIX定义了sigaction系统调用。

      #include <signal.h>
      
      int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact)

      调用sigaction会改变由signo表示的信号的行为,signo是除SIGKILLSIGSTOP外的任何值。act非空,将该信号的当前行为替换成参数act指定的行为。oldact非空,在其中存储先前(或者是当前的,如果act非空)指定的信号行为。
      结构体struct sigaction支持细粒度控制信号:

      struct sigaction {
        void (*sa_handler)(int);	/* signal handler or action */
        void (*sa_sigaction)(int, siginfo_t *, void *);		/* 新的表示如何执行信号处理函数 */
        sigset_t sa_mask;	/* 执行信号时被阻塞的信号集 */
        int sa_flags;	/* flags */
        void (*sa_restore)(void);	/* obsolete and non-POSIX */
      }

      如果sa_flags设置SA_SIGINFO标志,则由sa_sigaction来决定如何执行信号处理,提供有关该信号的更多信息和功能;否则,使用sa_handler处理信号,与C标准signal函数原型相同。

      信号集类型sigset_t表示一组信号集合,定义下列函数管理信号集:

      #include <signal.h>
      
      int sigemptyset(sigset_t *set);
      int sigfillset(sigset *set);
      int sigaddset(sigset *set, int signo);
      int sigdelset(sigset *set, int signo);
      int sigismember(const sigset *set, int signo);
  2. 父进程阻塞于accept慢系统调用时处理SIGCHLD信号可能导致父进程中止
    当SIGCHLD信号递交时,父进程阻塞于accept调用,accept是慢系统调用,内核会使accept返回一个EINTR错误(被中断的系统调用)。如果父进程不处理这个错误,就会中止。而这里父进程没有中止,是因为在注册信号处理函数mysignal中设置了SA_RESTART标志,内核自动重启被中断的accept调用。不过为了便于移植,必须为accept处理EINTR错误。
    慢系统调用的基本原则:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误,我们必须处理慢系统调用返回的EINTR错误。

  3. Unix信号是不排队的,所以使用非阻塞waitpid处理SIGCHLD信号
    Unix信号默认是不排队的。也就是说,如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞通常只递交一次。如果多个SIGCHLD信号在信号处理函数sig_chld执行之前产生,由于信号是不排队的,所以sig_chld函数只执行一次,仍会出现僵尸进程。信号处理函数应改为使用waitpid,以获取所有已终止子进程的状态,同时指定WNOHANG选项,告知waitpid在有尚未终止的子进程在运行时不要阻塞,从而直接从信号处理函数返回。

2.3 客户进程同时应对sockfd和stdin两个描述符,使用select I/O多路复用,使服务器进程一经终止客户就能检测到

启动客户/服务器对,然后杀死服务器子进程,从而模拟服务器进程崩溃的情况(注意这里对应的是服务器进程崩溃,而服务器主机崩溃是另外的情况)。子进程被杀死后,系统发送SIGCHLD信号给服务器父进程,父进程正确处理子进程异常终止,关闭子进程打开的所有文件描述符,从而引发服务器TCP发送一个FIN分节给客户TCP,并响应一个ACK分节。接下来服务器TCP期待TCP四次挥手的后两个分节,但此时客户进程阻塞在fgets调用上,等待从终端接收一行文本,无法执行readline函数读取服务器TCP发送的FIN分节代表的EOF,所以不能直接退出以向服务器TCP回送FIN分节,所以此时服务器TCP处于CLOSE_WAIT状态,客户TCP处理FIN_WAIT2状态,可以用netstat命令观察到套接字的状态:
process of server terminated prematurely
本例子的问题在于:当FIN到达套接字时,客户正阻塞在fgets调用上。客户实际上在应对两个描述符——套接字和用户输入,它不能单纯阻塞在这两个源中某个特定源的输入上,而是应该阻塞在其中任何一个源的输入上。这正是select和poll这两个函数的目的之一。
对于客户进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(输入已准备好被读取,或者描述符已能承载更多的输出),它就通知进程。这个能力称为I/O复用。I/O复用使进程阻塞在select或poll系统调用上,而不是阻塞在真正的I/O系统调用上。    

I/O复用的典型应用场景:
- 客户处理多个描述符(通常是交互式输入和网络套接字)
- TCP服务器既要处理监听套接字listenfd,又要处理已连接套接字connfd

select函数:
该函数允许进程指示内核等待多个事件(读、写、异常)中的任何一个发生,并只在有一个或多个时间发生或经历一段指定的时间后才唤醒。

#include <sys/select.h>
#include <sys/time.h>

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

timeout为NULL,一直阻塞于select调用,直到有一个描述符准备好I/O才返回;timeout不为NULL,阻塞一段固定时间,如果没有一个文件描述符准备好,一直等待这么长时间后返回;如果timeout值为0,则检查描述符后立即返回。
readfdswritefdsexceptfds为读、写和异常条件的描述符集。select使用描述符集,通常是一个整形数组,其中每个整数中的每一位对应一个描述符。举例来说,假设使用32位整数,那么该数组的第一个元素对应于描述符0~31,第二个元素对应于描述符32~61,依次类推。实现细节隐藏于fd_set数据类型,使用四个宏函数操纵描述符集:

void FD_ZERO(fd_set *set);	/* clear all bits in set */
void FD_SET(int fd, fd_set *set);		/* turn on the bit for fd in set */
void FD_CLR(int fd, fd_set *set);		/* turn off the bit for fd in set */
void FD_ISSET(int fd, fd_set *set);	/* is the bit of fd on in set? */

readfdswritefdsexceptfds三个参数中的某一个不感兴趣,则可以把它设为NULL。nfds指定待测试的描述符个数,其值为待测试的最大描述符加1,因为描述符是从0开始的。所以,[0, nfds)指定描述符的范围,在这个指定范围内,由readfdswritefdsexceptfds指定的描述符将被测试。
注意readfdswritefdsexceptfds都是“值-结果”参数,调用select时其用于指定要监听的描述符,从select返回时内核会更改这些描述符集,指示哪些描述符已就绪。所以每次重新调用select函数时,都要再次把所有描述符内所关心的位均置1。

但这样改进后,对于将输入重定向到文件的批量输入仍存在问题:我们现在的处理是当从标准输入读取到EOF时,str_cli函数就此返回到main函数,而main函数随后终止。在批量输入方式下,标准输入中的EOF并不意味着我们同时也完成了从套接字的读入:可能仍有请求在去往服务器的路上,或者仍有应答在返回客户的路上。
客户进程将makefile重定向到输入,得到的回射输出少于输入文件:
tcpcli_RST_error
服务器子进程的str_echo函数read收到RST错误:
tcpserv_RST_error
个人分析出现错误的原因为:当客户进程从标准输入读取到EOF时,str_cli返回到main函数,main函数随机退出,从而导致客户TCP发送RST分节到服务器子进程!

2.3 客户进程使用select,从标准输入读取到EOF后shutdown关闭写连接

终止网络连接的通常方法是调用close函数。不过close有两个限制,却可以使用shutdown避免:

  1. close把描述符引用计数减1,仅在该计数变为0时才关闭套接字。使用shutdown可以不管引用计数就激发TCP的正常连接终止序列。
  2. close终止读和写两个方向的数据传送。既然TCP连接是全双工的,有时我们需要告知对端我们已经完成了数据发送,即使对端仍有数据要发送给我们,这也正是str_cli函数在批量输入时的情况。使用shutdown可以关闭TCP连接读或写的任意一端。
#include <sys/socket.h>

int shutdown(int sockfd, int howto);

该函数的行为依赖于howto参数的值:

  1. SHUT_RD
    关闭连接的读这一半,套接字中不再有数据可接收,而且套接字接收缓冲区中的现有数据都被丢弃。进程不能再对这样的套接字调用任何读函数。对一个TCP套接字这样调用shutdown函数后,由该套接字接收的来自对端的任何数据都被确认,然后悄然丢弃。
  2. SHUT_WR
    关闭连接的写这一半,对于TCP套接字,这称为半关闭(half-close)。当前留在套接字发送缓冲区中的数据将被发送掉,后跟TCP的正常连接终止序列。进程不能再对这样的套接字调用任何写函数。

2.4 客户进程使用poll,从标准输入读取到EOF后shutdown写连接

select使用基于文件描述符的三位掩码解决方案,其效率不高:调用select时要向内核传递整个描述符集,而描述符集又是“值-结果”参数,select调用返回时内核会更改描述符集,将就绪描述符对应的位置位,所以每次调用select需要对描述符集重新初始化;select的文件描述符集是静态的,需要用户指定待测试的描述符个数,需要对这个描述符个数的值进行权衡:如果值很小,会限制select可监视的最大文件描述符值;如果值很大,效率会很低,因为大的位掩码操作效率不高,尤其是当无法确定集合是否是稀疏集合。
poll使用由nfds个pollfd结构体构成的数组,每个数组元素用于指定测试某个给定描述符fd的条件,数组大小nfds完全由用户指定,不像select要受到FD_SETSIZE和每个进程可打开的最大描述符数目的限制。用户不需要指定待测试的描述符个数,内核会检查数组中的每个元素。

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
	int fd;		/* file descriptor */
  short events;	/* requested events to watch */
  short revents;	/* returned events witnessed */
}

每个pollfd结构体指定一个被监视的文件描述符,其中events变量是要监视的文件描述符的事件的位掩码,revents变量是该文件描述符的结果事件的位掩码,events为调用值,revents为返回结果,从而避免使用“值-结果”参数,而select的描述符集为“值-结果”参数。