1.处理SIGCHLD信号
当编写fork子进程处理连接的服务器程序时,子进程退出会给父进程产生SIGCHLD信号,父进程若不处理该信号会导致僵尸进程。
处理SIGCHLD信号,使用waitpid调用,不能使用wait简单处理。一般的处理方法如下(信号处理函数):
void sig_chld(int signo) { pid_t pid; int stat; while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0 ) continue; return; }
2.捕获信号时,注意处理被中断的系统调用
信号处理可能会中断慢系统调用,所以我们必须对慢系统调用返回EINTR错误做准备。一般处理方法如下(以accept为例):
for (;;) { clilen = sizeof(cliaddr); if ((connfd = accept(listenfd, (SA *)&cliaddr, &clilen) < 0) { if (errno == EINTR) continue; else err_sys("accept error"); }
此法对accept、read、write、select、open等合适;但connect调用不能重启,若connect返回EINTR,我们不能再调用它,否则立即返回错误。
3.accept返回前连接夭折的处理
连接在listen后建立,在accept前夭折,此时accept可能会返回错误,对此必须处理。
但对于夭折的连接处理依赖于实现,源自Berkekey的实现在内核中处理夭折的连接,服务器进程根本看不到,若当前没有新连接,则accept阻 塞;大多数SVR4实现会返回一个错误给进程,作为accept的返回,此值可能是EPROTO(“协议错”)errno值;Posix.1g要求返回 ECONNABORTED(“软件引起的连接夭折”)。
所以,在ECONNABORTED错误情况下,服务器可以忽略错误而简单的再调用accept一次。
经过测试,在Solaris8和AIX4.3下返回ECONNABORTED,但在SCO Openserver 5.05下返回EINVAL。
4.具有多个输入的处理
当工作在有两个或多个描述字的情况下,不能只阻塞于某个特定源输入中,而是应该阻塞于任一个源的输入中。否则在网络描述字中会出现有异常数据而没有读到,导致出错的情况。
方法是使用select或poll。
5.SIGPIPE的产生和处理
在一个服务器进程终止的情况下,终止关闭了描述字,此时客户对此描述字写,服务器TCP接收到来自客户的数据,由于先前打开那个套接口的进程已经终 止,所以以RST响应。客户端可能会收到先前关闭时的FIN,也可能收到后面的RST,这依赖于当时的具体情况。因为同时有FIN和RST时,RST优先 于FIN。
当进程向接收了RST的套接口进行写操作时,内核个该进程发一个SIGPIPE信号,此信号的缺省行为是终止进程,所以,进程必须捕获该信号以免不情愿的被终止。
进程不论是捕获了该信号并从其信号处理程序返回,还是不理会该信号,写操作都会返回EPIPE错误。
写一个已接收FIN的套接口是可行的,但写一个已接收了RST的套接口则是错误的。
处理SIGPIPE的建议方法取决于它发生时应用想做什么。如果没有什么特殊的情况处理,可将信号处理方法简单的设置为SIG_IGN,并假设后续 的写操作将捕捉EPIPE错误并终止。若在信号处理程序中处理,要注意的是,如果使用了多个套接口,该信号的递交无法知道是哪个套接口出的错。
6.处理服务器主机崩溃
假设服务器主机已经崩溃,客户此时写数据,客户TCP会持续重传数据分节,若客户的数据分节根本没有响应,则错误为ETIMEDOUT;若某些中间 路由器判定服务器主机不可达,且以一个目的地不可达饿ICMP消息响应,则错误是EHOSTUNREACH或ENETUNREACH。
以上情况只有向服务器主机发送数据时,才能检测出它已经崩溃。如果不主动发送也想检测出崩溃情况,则需要设置套接口选项SO_KEEPALIVE。
7.处理服务器主机崩溃重启
当服务器主机崩溃并重启后,客户向服务器发送数据,由于服务器重启,它的TCP丢失了崩溃前的所有连接信息,所以服务器TCP对接收到的客户数据分节以RST响应。若客户阻塞于读,则返回ECONNRESET错误。
8.处理服务器主机关机
服务器关机会终止所有进程,进程退出时会关闭描述字,所以该情况的处理类似于SIGPIPE中服务器进程终止的讨论。
9.网络函数的可重入问题
历史上,gethostbyname、gethostbyname2、gethostbyaddr、getservbyname和 getservbyport是不可重入的。一些支持线程的实现提供了相应的可重入版本(以_r结尾),也有些实现提供了这些函数的使用线程特定数据的可重 入版本。
inet_pton和inet_ntop总是可重入的。
历史上inet_ntoa是不可重入的,但一些实现提供了使用线程特定数据的可重入版本。
getaddrinfo只有在它自己调用的是可重入函数时才是可重入的。
getnameinfo只有在它自己调用的是可重入函数时才是可重入的。
10.套接口设置超时的方法
有三种方法:
- 调用alarm,在到达指定时间时产生SIGALRM信号,必要时结合sigsetjmp和siglongjmp进行使用。
- 使用select的时间机制来设置超时。
- 使 用新的SO_RCVTIMEO和SO_SNDTIMEO套接口选项。该法的缺点是不是所有的实现都支持它们,一旦为每个描述字设置了这个选项,并指定了超 时值,那么这个超时值对该套接口上的所有读/写操作都起作用,所以只需设置一次选项。但它们只能用于读/写操作,没法为connect设置超时。当超时发 生,函数返回EWOULDBLOCK。
11.辅助数据
在sendmsg和recvmsg中可以使用msghdr结构中的msg_control和msg_controllen成员发送和接收辅助数据(ancillary data)。辅助数据的另一个叫法是控制信息(control information)。
辅助数据的各种用法如下:
协议 | cmsg_level | cmsg_type | 说明 |
---|---|---|---|
IPv4 | IPPROTO_IP | IP_RECVDSTADDR | 接收UDP数据报的目的地址 |
IP_RECVIF | 接收UDP数据报的接口索引 | ||
IPv6 | IPPROTO_IPV6 | IPV6_DSTOPTS | 指定/接收目标选项 |
IPV6_HOPLIMIT | 指定/接收跳限 | ||
IPV6_HOPOPTS | 指定/接收步跳选项 | ||
IPV6_NEXTHOP | 指定下一跳地址 | ||
IPV6_PKTINFO | 指定/接收分组信息 | ||
IPV6_RTHDR | 指定/接收路由头部 | ||
Unix域 | SOL_SOCKET | SCM_RIGHTS | 发送/接收描述字 |
SCM_CREDS | 发送/接收用户凭证 |
辅助数据由一个或多个辅助数据对象组成,每个对象由一个cmsghdr结构开头,该结构在<sys/socket.h>中定义如下:
struct cmsghdr { socklen_t cmsg_len; /* length in bytes, including this structure */ int cmsg_level; /* originating protocol */ int cmsg_type; /* protocol-specific type followed by unsigned char cmsg_data[] */ };
实际数据在cmsghdr结构后面,msg_control指向的辅助数据必须按cmsghdr结构进行对齐,所以在cmsg_type成员和实际数据之间可能有填充字节,在数据之后,下一个对象之前也可能有填充字节。使用如下的宏屏蔽可能出现的填充字节:
#include#include struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *mhdrptr); 返回:指向第一个cmsghdr结构的指针,无辅助数据时为NULL struct cmsghdr *CMSG_NXTHDR(struct msghdr *mhdrptr, \ struct cmsghdr *cmsgptr); 返回:指向下一个cmsghdr结构的指针,不再有辅助数据对象时为NULL unsigned char *CMSG_DATA(struct cmsghdr *cmsgptr); 返回:指向与cmsghdr结构关联的数据的第一个字节的指针 unsigned int CMSG_LEN(unsigned int length); 返回:给定数据量下存储在cmsg_len中的值 unsigned int CMSG_SPACE(unsigned int length); 返回:给定数据量写一个辅助数据对象的总大小
CMSG_LEN和CMSG_SPACE的区别在于,前者不将填充字节计算在内,因此等于cmsg_len的值,但后者将这些填充字节都计算在内,因此在动态申请辅助数据对象的数据空间时使用这个值。
12.如何得知套接口接收队列中有多少数据?
有三种方法:
- 如果在没有数据可读时还有其他事情要做,为了不阻塞在内核中,可以使用非阻塞I/O。
- 如果想检查数据而使 数据仍留在接收队列中,可以使用MSG_PEEK标志。如果想避免阻塞,可以把此标志和非阻塞套接口相结合,或与MSG_DONTWAIT标志结合使用。 注意的是,一个字节流套接口接收队列的数据量在两次连续的recv调用之间可能会改变,而UDP套接口不会。
- 一些实现支持ioctl的FIONREAD命令。Ioctl的第三个参数是一个指向整数的指针,在该整数中返回的值是套接口接收队列中数据的字节数。这个值是队列中字节数的总和,对UDP套接口来 包括在队列中的所有数据报。
13.UNIX域协议
UNIX域协议并不是一个实际的协议族,它只是在同一台主机上进行C/S通信时,使用与在不同主机上的C/S通信时相同的API的一种方法。
UNIX域提供了两种类型的套接口:字节流套接口(与TCP类似)和数据报套接口(与UDP类似)。
使用UNIX域协议有三个原因:
- 在源自Berkeley的实现中,当通信双方位于同一台主机上时,UNIX域套接口的速度通常是TCP套接口的两倍。
- UNIX域套接口可以用来在同一台主机上的各进程之间传递描述字。
- UNIX域套接口的较新实现中可以向服务器提供客户的凭证(用户ID和组ID),这能提供附加的安全检查。
UNIX域套接口地址结构如下:
#include struct sockaddr_un { uint8_t sun_len; sa_family_t sun_family; /* AF_LOCAL */ char sun_path[104]; /* null_terminated pathname */ };
sun_path数组中存放的路径名必须以空字符结尾。系统提供了SUN_LEN宏,他以一个指向sockaddr_un结构的指针为参数,返回该 结构的长度,长度里包括路径名中的非空字节数。未指定地址以空字符串的路径名表示,这是UNIX域中与IPv4的INADDR_ANY常值或IPv6的 IN6ADDR_ANY_INIT常值等价的一个地址。
注意:给UNIX域套接口bind的路径名如果在文件系统中已经存在,则bind将失败。为预防此种情况,可以先调用unlink。
14.UNIX域套接口使用套接口函数的一些差别和限制
下面是Posix.1g的一些要求,并不是所有的实现都做到了这一级:
- bind建立的路径名的缺省访问权限应为0777,并被当前的umask值修改;
- UNIX域套接口相关联的路径名应为一个绝对路径名,而不是相对路径名;
- connect使用的路径名必须是一个绑定在某个已打开的UNIX域套接口上的路径名,而且套接口的类型(字节流或数据报)也必须一致;
- 用connect连接UNIX域套接口时的权限检查和用open以只写方式访问路径名时完全相同;
- UNIX域字节流套接口和TCP套接口类似:他们都为进程提供一个没有记录边界的字节流接口;
- 如果UNIX域字节流套接口的connect调用发现套接口的队列已满,会立即返回一个ECONNREFUSED错误,这和TCP有所不同;
- UNIX域数据报套接口和UDP套接口类似:都提供一个保留记录边界的不可靠的数据报服务;
- 与UDP套接口不同,在未绑定的UNIX域套接口上发送数据报不会给它捆绑一个路径名。同样,与TCP和UDP不同的是,个UNIX域数据报套接口调用connect不会捆绑一个路径名。
15.描述字传递机制
使用UNIX域套接口可以在两个没有关系的进程之间传递描述字。4.4BSD的技术允许一次sendmsg传递多个描述字,而SVR4的技术一次只能传递一个描述字。
在两个进程之间传递描述字的步骤如下:
- 创建一个字节流或数据报的UNIX域套接口。如果两进程有父子关系,可以使用socketpair,否则服务器必须创建一个UNIX域套接口,bind一个路径名,让客户connect到这个套接口。
- 进程可以用任何返回描述字UNIX函数打开一个描述字,如open、pipe、mkfifo、socket或accept。可以在进程之间传递任何类型的描述字。
- 发 送进程建立一个msghdr结构,其中包含要传递的描述字,Posix.1g说明该描述字作为辅助数据发送(msghdr结构的msg_control或 msg_accright成员),发送进程调用sendmsg通过第一步得到的UNIX域套接口发出描述字。这时该描述字是“在飞行中(in flight)”,即使sendmsg之后,在recvmsg之前将该描述字关闭,它仍会为接收进程保持打开状态。描述字的发送导致它的访问计数加1。
- 接 收进程调用recvmsg,在UNIX域套接口上接收描述字。通常接收进程收到的描述字的编号与发送进程中的描述字编号不同。C/S之间必须有某种应用协 议,使接收方知道何时接收描述字,如果接收方调用recvmsg但没有分配接收描述字的空间,而且一个描述字已被传递并正待读出,这个已传递的描述字就会 关闭。在用recvmsg接收描述字时要避免使用MSG_PEEK标志,否则后果不可预料。
16.非阻塞套接口I/O
概述
缺省状态下,套接口是阻塞方式的,当一个套接口调用不能立即完成时,进程进入睡眠状态,等待操作完成。可能阻塞的套接口调用分为如下四种:
- 输入操作:read、readv、recv、recvfrom和recvmsg函数。在一个非阻塞套接口上,如果输入操作不能满足,它们将会立即返回一个EWOULDBLOCK错误。
- 输 出操作:write、writev、send、sendto和sendmsg函数。在一个非阻塞套接口上,如果发送缓冲区没有空间,它们将会立即返回一个 EWOULDBLOCK错误,如果发送缓冲区有一些空间,返回值为内核能向缓冲区中拷贝的字节数(不足计数(short count))。
- 接收外来连接:accept函数。在非阻塞套接口上调用accept函数,而且没有新的连接,将返回EWOULDBLOCK错误。
- 初始化外出的连接:用于TCP的connect函数。在非阻塞的套接口上调用connect,而且连接不能马上建立,连接的建立过程将开始,但返回一个EINPROGRESS错误。也存在连接立即建立的情况。
对于非阻塞I/O不能马上完成返回的错误码不同实现有差异:系统V返回EAGAIN,而Berkeley返回EWOULDBLOCK,幸好这两个错误码在多数实现中值相等。
非阻塞connect
非阻塞connect有如下三种用途:
- 我们可以在三路握手的同时做一些其他的处理;
- 可以用这种技术同时建立多个连接;
- 我们用select等待连接的完成,因此可以给select设置一个时间限制,从而缩短connect的超时时间。
处理非阻塞connect有一些细节需要处理:
- 即使套接口是非阻塞的,如果连接的服务器在同一台主机上,在调用connect时连接通常会立刻建立。
- 源自Berkeley的实现有两条与select和非阻塞I/O相关的规则:(1)当连接成功建立时,描述字变成可写;(2)当连接建立出错时,描述字变成既可读又可写。
非阻塞connect有许多移植性的问题,如getsockopt等,需要注意。
被中断的阻塞connect
一个被中断的阻塞connect不自动重启的情况下,它会返回EINTR,此时我们不能再调用connect等待连接建立完成,这样做将返回EADDRINUSE错误。
在这种情况下要做的是要么关闭套接口,重新调用socket和connect;要么调用select,和非阻塞connect一样处理,使select在连接建立成功(使套接口可写)或连接失败(使套接口科可读又可写)时返回。
非阻塞accept
如前“accept返回前连接夭折”所述,在服务器调用accept之前客户如果放弃这个连接,源自Berkeley的实现不对服务器返回这个夭折的连接,而其他实现应返回ECONNABORTED错误,但一般返回EPROTO错误。
如果在accept之前使用select来测试连接是否准备好,要注意的是在select和accept之间如果连接夭折,源自Berkeley的实现会导致accept阻塞,直到其他某个客户建立一个连接为止。所以使用select并不能保证accept不会阻塞。
解决方法是:
- 如果用select来获取何时有连接已就绪可以accept时,总是把监听套接口设置为非阻塞的;
- 并且在 后面的accept中忽略以下错误:EWOULDBLOCK(源自Berkeley的实现在客户放弃连接时出现的错误)、 ECONNABORTED(Posix.1g的实现在客户放弃连接时出现的错误)、EPROTO(SVR4的实现在客户放弃连接时出现的错误)和 EINTR(如果信号被捕获)。
17.服务器程序常见设计方法
服务器程序设计方法列出如下9种:
- 迭代服务器(无进程控制);
- 简单并发服务器,每个客户fork一次;
- 预先派生子进程,每个子进程相互独立调用accept;
- 预先派生子进程,使用文件上锁保护accept;
- 预先派生子进程,使用线程互斥锁上锁保护accept;
- 预先派生子进程,父进程向子进程传递套接口描述字;
- 并发服务器,每个客户请求创建一个线程;
- 预先创建线程服务器,使用互斥锁上锁保护accept;
- 预先创建线程服务器,由主线程调用accept。
有如下结论:
- 当系统负载较轻时,传统的并发服务器程序模型就够了。
- 相对于传统的每个客户一次fork设计,预先创建一个进程池或线程池可以减少进程控制CPU时间,大约可减少10倍以上。
- 某些实现允许多个子进程或线程阻塞在accept上,然而在另一些实现中,我们必须使用文件锁、线程互斥锁或其他类型的锁来确保每次只有一个子进程或线程在accept。
- 一般来将,所有子进程或线程都调用accept要比父进程或主线程调用accept后将描述字传递个子进程或线程来得快且简单。
- 由于select冲突的存在,让所有子进程或线程阻塞在同一监听套接口的accept调用上要比让它们阻塞在select调用上更为可取。
- 使用线程通常比使用进程快。不过选择每个客户一个子进程还是每个客户一个线程取决于操作系统提供什么以及需什么其他程序来服务各个客户。
18.注意网络编程的移植性问题
在winsock中套接口描述符用SOCKET而不是int类型定义。
尽量避免在套接口字上使用read和write函数,因为在WIN32中不支持它们。我们一般用recv、recvfrom和recvmsg来表示“读”,用send、sendto和sendmsg表示“写”。
19.注意对等方的不合理行为
软件应编写成能够处理任何想象的到的错误,不管该错误可能发生的概率是如何的小。
在网络编程中,应当牢记的规则是:不能假设对等方会严格遵守应用程序协议,甚至是在我们实现双方协议的时候也是这样。
如在长链中检查客户端是否终止,检查输入的有效性,注意缓冲区的溢出和指针失控。
20.开发和使用应用程序“框架”
大多数的TCP/IP应用程序属于下面四种之一:TCP服务器、TCP客户端、UDP服务器、UDP客户端。
每类应用程序都有类似的代码来完成初始化工作,所以可以定义一些程序框架(模板)。使用时根据情况稍做修改即可。
21.在应用程序中中实现keep-alive机制
当应用程序启用TCP的keep-alive机制时,TCP就会在连接已经空闲了一定时间间隔后发送一个特殊的段给对等方。如果对等方主机可以到达 而且对等方应用程序依然运行,对等方TCP就会响应一个ACK应答,此时通讯正常,TCP将发送keep-alive空闲时间重置为0,应用程序不会接收 到消息交换的任何通知。
如果对等方主机可以到达,但对等方应用程序没有运行,则对等方TCP响应RST,发送方的TCP撤消连接并返回ECONNRESET错误给应用程序。
如果对等方主机没有响应ACK或RST消息,发送方TCP继续发送keep-alive探询消息,直到它认为对等方不可达到或已经崩溃。此时撤消连 接并通知应用ETIMEDOUT错误。如果路由器已经返回主机或网络不可到达的ICMP消息的话,则返回EHOSTUNREACH或 ENETUNREACH错误。
TCP的keep-alive机制的问题是检测的空闲时间太长(至少是2小时),而且它不仅检测死连接,同时也撤消它们,有时这并不是应用程序所期望的。
可以在应用程序中实现类似的机制来解决keep-alive的问题,通过编写心博(heartbeat)函数。
22.理解TCP的写操作
应用程序写操作成功并不表示数据已经发送到对等方。写操作把数据放到发送缓冲区中,除非TCP发送缓冲区已满,否则写操作是不会阻塞的,这意味着写操作几乎总是立即返回,而且如果它返回了,也不能保证所写数据的位置。
实际上,当写操作返回时,写入的部分或全部数据可能仍旧在排队等待传输,如果此时对等方主机或应用程序崩溃的话,数据将会丢失。
因为写操作可能在数据实际发送之前就已经返回,所以当传输发生错误时,该错误通常是从下一个操作返回。写操作返回的错误仅仅是那些在调用写操作时就已经发生的错误,除了EPIPE错误。
23.使应用程序事件驱动
利用select来实现一个通用的分时机制,允许在一定时间间隔后指定一个必须发生的事件,而且使该事件在指定的时间上异步发生。
24.不要试图绕过TIME-WAIT状态
TIME-WAIT状态是TCP可靠性中很重要的一部分,程序员不应该试图绕过它,虽然可以使用SO_LINGER套接口选项来取消它。
25.服务器应当设置SO_REUSEADDR选项
对于TCP服务器总是应该设置SO_REUSEADDR选项,否则没法重新启动一个在TIME-WAIT状态中还存在连接的服务器。
该选项必须在bind()之前调用setsockopt()设置。
26.尽量使用大型写操作代替多个小规模写操作
使用大型写操作的原因除了避免上下文切换外,主要目的是避免因过多的小规模写操作导致Nagle算法的影响,从而对程序性能造成极大的负面影响。
虽然可以使用套接口选项TCP_NODELAY来禁止Nagle算法,但不是解决问题的方法。
所以要减少小规模的写操作,多次写合并成一次写。实现的一种方法是使用聚集写:readv和writev。在Winsock中则使用不同但类似的接口:WSAsend。
27.注意异步connect的可移植问题
把connect设置为非阻塞的主要用途是使connect具有超时机制。使用select是实现的一种方法。但使用该法有比较多的可移植问题。
程序的关键是在判断连接是否成功的地方。下面是UNIX版本:
int isconnected(SOCKET s, fd_set *rd, fd_set *wr, fd_set *ex) { int err; int len = sizeof(err); errno = 0; /* assume no error */ if (!FD_ISSET(s, rd) && !FD_ISSET(s, wr)) return 0; if (getsockopt(s, SOL_SOCKET, SO_ERROR, &err, &len) < 0) return 0; errno = err; /* in case we're not connected */ return err == 0; }
在UNIX中,连接一旦建立,则套接口就可以执行写操作;如果发生了错误,套接口就既可读又可写。但我们不能依据这些来判断是否成功,要使用getsockopt来取错误状态。
但getsockopt也有问题,在UNIX的有些实现中,如果套接口发生了错误则getsockopt返回-1;而其他实现仅返回套接口的错误状态而让调用者来检查。所以需要对两种情况都考虑。
下面是Winsock的版本:
int isconnected(SOCKET s, fd_set *rd, fd_set *wr, fd_set *ex) { WSASetLastError(0); if (!FD_ISSET(s, rd) && !FD_ISSET(s, wr)) return 0; if (FD_ISSET(s, ex)) return 0; return 1; }
在Winsock下,当使用select时,在非阻塞套接口上调用connect返回的错误有异常事件来指示。
28.避免数据拷贝
在许多网络程序中,把数据从一个缓冲区拷贝到另一个缓冲区的的操作占用了大多数的处理时间。所以,在一个进程里避免这种拷贝是一个好的程序设计习惯。
一种方法是预留空间。如:
rc = read(fd, buf + sizeof(struct hrd), sizeof(buf) - sizeof(struct hdr));
在多进程环境中,可以使用共享内存区来避免数据拷贝。在UNIX下使用shmget等调用,在Windows下使用类似于文件映射的机制来实现。
29.在使用之前置sockaddr_in结构为0
通常我们只使用sockaddr-in结构中的三个域:sin_family、sin_port和sin_addr,但许多实现还包括了其他的域,这些域的值影响着套接口操作。所以,在使用该结构之前把它初始化为0是一个很好的习惯。
30.理解缓冲区大小对TCP性能的影响
TCP缓冲区的大小依赖于应用程序,对于一个交互式的应用程序如telnet,小的缓冲区就足够了。
对于非交互式的应用,应当设置它们的发送缓冲区至少为3倍的MSS大小,这要在listen或connect之前执行。