学习笔记目的
此文档记录本人学习Unix Network Programming 3rd verion volumn I的一些笔记,我只将觉得重要或经过一番功夫才理解的内容记录下来,方便以后回顾。
第二章 传输层TCP,UDP和SCTP
2.10 TCP端口号和并行服务器
tcp是通过一对socket(socket pair)来区分socket通讯的,可以这么理解,socket = ip + port,
socket pair = client socket + server socket。 所以,当使用一个监听器监听时,一旦accept到一个connect后,可以,将这个socket交给一个线程或进程执行任务,在线程中执行这个任务的同时,主线程仍然可以accept其他client socket的链接。因为,每个tcp connection的socket pair中的client socket是不一样的,所以tcp是可以区分这些连接。下面这个举个列子,如下图所示,
客户socket ,简写为CS1 = 206.168.112.219:1500,发起了链接,链接到服务socket ,简写为SS = 12.106.32.254:21。连接后,服务器fork一个进程,该进程处理CS1的相关操作。接下来,如图:
客户socket,简写为CS2 = 206.168.112.219:1501,发起链接,仍然链接SS,此时服务器在fork一个进程处理该链接,此时CS1和SS的链接仍然存在,并且SS还可以监听其他链接。
通过以上的描述,你可以知道socket只有在accept的时候会block,accept后只要fork或采用多线程,处理请求,就可以继续监听其他请求,也就是说,监听socket和accept之后的socket是可以同时存在,互相不影响的。
我以前误以为监听socket在accept的socket销毁后,才能继续监听,否则会出现混乱,因为他们会同时使用一个端口和一个IP(上面的列子,都是12.106.32.254:21)。出现这个误区的原因是因为我没有理解TCP是根据socket pair区分连接,而不是根据单独的socket。
2.11 缓存大小和限制
对于同步的socket(blocking socket),write函数的内部如何操作?
write函数会将应用程序buffer中的数据写到Tcp buffer,这里会发生一次数据copy,因为tcp需要得到这份数据进行重传。TCP buffer是有大小的,可以通过SO_SNDBUF来设置,如果满了却没有装满应用程序的数据,那么应用程序进程就会sleep,也就是堵塞了,直到应用程序的数据全部copy到tcp buffer中,write程序才会返回(这里针对默认的blocking socket)。所以,可以想一下,如果应用程序每次都传超过SO_SNDBUF的数据,那么每次至少会sleep一次,效率会大打折扣。而且,write返回后,并不意味者客户端接收到了所有数据,他只表明应用程序中的数据全部copy到tcp buffer中,仅此而已,接下来由tcp负责将数据发送给客户端。下图是tcp buffer和应用程序buffer的示意图:
第三章 套接字介绍
3.2 Socket地址结构
每一种协议都有地址结构,这些地址结构都是以“sockaddr_XXX”的形式命名,比如IP v4的地址结构为sockaddr_in,IP v6为sockaddr_in6,链路协议sockaddr_dl等等。
IP v4结构定义
struct in_addr { in_addr_t s_addr; // 32比特的IP v4地址,网络字节序 // 需要函数将点分十进制的地址转化为该值 };
struct sockaddr_in { uint8_t sin_len; // 结构长度,应用程序开发人员无需关系 sa_family_t sin_family; // 恒为AF_INET in_port_t sin_port; // 16比特TCP或UDP端口,网络字节序 struct in_addr sin_addr; // 32比特的IP v4地址,网络字节序 char sin_zero[8]; // 没有使用 }; |
总结一下:
l 都是网络字节序
l 只需要关心16位的port和32位的IP address
l sin_family恒为AF_INET
3.3 “值-结果”参数(value-result arguments)
值-结果参数的意思就是in-out参数,即作为函数的参数,有作为函数的一部分结果,
线面的代码就是值结果参数
1. struct sockaddr_un cli; /* Unix domain */ 2. socklen_t len; 3. 4. len = sizeof(cli); /* len is a value */ 5. getpeername(unixfd, (SA *) &cli, &len); 6. /* len may have changed */ |
如果cli是sockaddr_in(或sockaddr_in6),那么len恒为为16(或28),而sockadr_un的长度是会变化的,地址结构的可以参见下图:
3.4 字节序函数
字节序
对于单字节数据类型如char来说,无所谓字节数序的问题,但是对于两个或两个以上的数据而言,字节序就很重要,如果字节序不统一,将会出现严重的数据兼容问题。比如short,是由两个字节组成,究竟是高位字节存在内存低位,还是低位字节存在内存低位(前者称为big-endian byte order,后者称为little-endian byte order)。答案是,不确定。不同操作系统自己定义。我们可以做的是保持我们的字节序与环境一致,而不需要关心到底是big-endian还是little-endian。下图表示不同字节序在内存中的结构:
网络字节序
网络字节序规定数据在网络中传输的字节数序,是统一的,每一个协议实现的厂家必须遵守该字节序,否则无法与其他协议通讯。由于历史原因,网络字节序使用big-endian byte order(高位数据存在内存低位,与手写整数的顺序类似)。
本机字节序
本机字节序没有统一的规定,不同操作系统厂家会只有自己的本机字节序,下面的代码可以判断当前OS本机字节序。
#include <iostream> using namespace std; int main() { union { short s; char bytes[2]; }un; short sSample = 0x0102; un.s = sSample; if (sizeof(un.s) == 2) { if (un.bytes[0] == 1 && un.bytes[1] == 2) { cout << "big-endian" << endl; } else if (un.bytes[0] == 2 && un.bytes[1] == 1) { cout << "little-endian" << endl; } else { cout << "unknow" << endl; } } else { cout << "size of short is not 2, but " << sizeof(short) << endl; } return 0; } |
字节序函数
在进行网络编程时,我们不需要关心机器字节序和网络字节序到底是little-endian还是big-endian,我们只需要知道数据在当前机器上的进程处理时,需要使用本机字节序,当数据在网络上传递时,需要使用网络字节序。通过下面这四个函数,可以方便的进行本机与网络字节序之间的转换:
#include <netinet/in.h> uint16_t htons(uint16_t host_16_bit_value); uint32_t htonl(uint32 _t host_32_bit_value); both return: value in network byte order uint16_t ntohs(uint16_t net_16_bit_value); uint32_t ntohl(uint32_t net_32_bit_value); both return: value in host byte order |
记这些函数的技巧:
l h代表host,即本机;
l n代表net,即网络;
l l代表long,即32位;
l s代表short,即16位。
3.5 字节操作函数
Unix下有两组字节操作函数,一组以b(byte)开头,是socket库提供的自己操作函数,一种以mem(memory)开头,由ANSI C提供。
#include <strings.h> // 注意这里不是<string.h>,多了一个s void bzero(void* dest, size_t nbyte); void bcopy(const void* src, void* dest, size_t nbyte); int bcmp(const void* ptr1,void* ptr2, size_t nbyte); // 返回第一个不想等的byte比较结果 /* 总结: l 无法修改的都是用const修饰, l 长度都是size_t,指的的src的长度 l bzero比较好用,因为只有两个参数 */ |
#include <string.h> void* memset(void* dest, int c, size_t len); void* memcpy(void* dest, const void* src, size_t nbyte); void* memcmp(const void* ptr1, const void* ptr2, size_t nbytes); /* 总结: l memcpy的记忆方式, dest = src l memset的记忆方式, 长度在最后面 l 据说bcopy会正确处理越界(overlap)情况,而memcpy不行,不知如何正确处理 */ |
3.6 函数inet_aton,inet_addr和inet_ntoa
这几个函数都是用于点分十进制IP和网络字节序IP相互转换。
#include <arpa/inet.h> /* * 注意: * 1 下面一对函数,相互转换,a代表字符串,n代表网络字节序 * 2 没有长度参数,因为点分十进制额字符串长度比较固定,程序可以分析 * 3 inet_ntoa接收的参数是值,而不是指针,比较少见,而且返回的值是在静态内容中, * 且运行修改,因为不是const * 4 inet_aton 返回1代表成功转换,0代表失败,可以理解为true和false, * 与一般的0标识成功有点不同 */ int inet_aton(const char* strptr, struct in_addr* addrptr); char* inet_ntoa(struct in_addr inaddr); /* * 注意: * 此方法过时,因为文档不全,更主要的是对于“255.255.255.255”转换得到结果与错误码相同,有缺陷,所以不建议使用。虽然使用起来,比inet_aton方便,但是隐患较多,所以最好不要使用。 */ in_addr_t inet_addr(const char* strptr); |
3.7 函数inet_pton和inet_ntop
这一对函数与inet_aton/inet_ntoa类似,但是支持Ipv6,添加了一个family参数接受AF_INET对应IPV4,AF_INET6对应Ipv6。需要解释一下”p”和”n”分别代表presentation和numeric。inet_pton/inet_ntop的函数接口更为一致。上一节提到过,inet_ntoa传递的是in_addr值,而不是指针,这与一般的方法使用上不是很一致。随着IP v6的普及,使用这一对函数,应该是大势所趋。下面来看看他们的接口定义:
#include <arpa/inet.h> /* * 参数的顺序很好记忆 * numic =>prensentation 所以地址在前,字符串在后 * * 此函数不提供内置的静态空间存储地址,需要用户提供地址,好在buffer的长度使用了 * 两个常量定义 * #include<netinet/in.h> * #define INET_ADDRSTRLEN 16 // for IPv4 dotted-decimal * #define INET6_ADDRSTRLEN 46 // for IPv6 hex string * 返回的地址与传入的地址一样提供的地址,如果转换过程出现问题,将会返回NULL * 实力代码 char szIP[INET_ADDRSTRLEN]; if (inet_ntop(AF_INET, &foo.sin_addr, szIP, sizeof(szIP)) == NULL) { cout << “failed to convert sin_addr”<< endl; } */ const char* inet_ntop(int family,const void*addrptr, char* addrstr, size_t len ); /* * 参数的顺序很好记忆 * presention =>numeric 所以字符串在前,地址在后 * 在这里IP字符串地址使用const修饰的,所以不需要长度。 * 成功返回1,格式错误返回0,其他错误返回-1(在linux下,发现任意一个为null,均会core dump,报“segment fault”,C/C++程序操作NULL很容易core dump,所以使用这些函数时,不要心存侥幸不会接受到NULL指针,一定要验证是否是NULL) */ int inet_pton(int family, const char* szIP, void* addrptr); |
第四章 TCP套接字基础
4.2 函数socket
socket函数应该是tcp socket中使用十分频繁的函数,它用于创建套接字socket,所有的网络相关的操作都与该函数的返回值有关,所以这个函数十分重要。
#include <sys/socket.h> /* * 创建套接字对象 * @param family: 地址族,如AF_INET, AF_INET6等 * @param type: 什么样的socket,子啊IP v4上,可以理解为是TCP或是UDP * @param protocol: 什么协议,为0时,选择系统默认选项,一般都为0. * @return: 成功返回非负数fd(file descriptor),失败返回-1 *P.S. : 如果socket被close了,说明该socket的状态到达终结状态,资源被系统回收,不能 * 再使用了。 * 使用示例代码: // 创建Ipv4的TCP套接字 int iSock = socket(AF_INET, SOCK_STREAM, 0); if (iSock < 0) { cout << “failed to create a tcp socket” << endl; exit(1); } */ int socket(int family, int type, int protocol); |
下面是一些常用的套接字创建组合
4.3 函数connect
connect函数触发tcp三次握手,没有指定客户端一定需要在connect函数调用之前使用bind函数,但是如果客户端使用了bind,那么客户段就指定了自己的端口和ip。否则内核会自动为客户端socket分配ip(多网卡需要选择ip)和端口。
#include <sys/socket.h> /* * @param iSock:一个可以用的socket描述符 *@param sa: 通用的地址,使用时要强行转换 *@param len: sa的长度。 *@return : 0标识正常,-1标识失败 */ int connect(int iSock, struct sockaddr * sa, int len); |
对于connect,成功说明三次握手成功,与对方建立了套接字链接,但是失败有下列常见情况
1 找不到对应IP,链接超时,会等到超市后,才报错。会产生ETIMEDOUT错误
2 对方相关端口没有打开,通常是“对方积极拒绝(connection refused)”,错误代码是ECONNREFUSED
3 对方IP不可到达,产生EHOSTUNREACH或 ENETUNREACH错误代码。
在局域网中编写socket程序,一般前两个情况比较常见。
4.4 函数bind
bind是将socket与特定的端口或地址进行绑定,相当于为端口和套接字设定指定的IP和端口。一般服务器会bind端口,而IP使用通配符(wildcard)。而客户端socket不用bind端口和IP地址。但是,这并不意味着客户端socket不能bind,只是这样做没有多大意义,因为如果port为0时,内核会自动分配一个没有使用的端口给客户端socket。Ipv4的IP通配符为INADDR_ANY,使用时最好使用htonl将其转为网络字节序并付给sockaddr_in.sin_addr,这样内核会自动为服务器socket指定IP。为什么需要指定IP呢,因为一台机器可以装多个网卡,这样就会有多个IP,所以有时候需要指定IP进行绑定。下面,来看看bind的函数接口:
#include <sys/socket.h> /* * @param sockfd 套接字 * @param sockaddr 被绑定的地址结构 * @param len: 地址长度 * @return:0标识成功,-1标识失败 */ int bind(int sockfd, const sturct sockaddr* sa, socklen_t len); |
bind的常见错误是“端口重绑定”,也就是bind到一个已经使用的端口,有时候也有可能是前一个socket虽然被进程close了,但是内核还没有完全释放资源,导致无法绑定,这样可以设置socket参数SO_REUSEADDR,用来重用该地址。如果不实用bind,系统会自动设定ip和地址,使用了就会更具进程指定的端口和ip绑定。一般只会指定端口,而IP使用通配符,让内核帮你指定。除非有特别需求,才会指定需要绑定的IP。
4.5 函数listen
此函数的作用是将socket转化为被动socket,也就是服务器socket,接受客户端socket的请求。listen函数调用顺序实在socket,bind和accept之间。下面看看listen接口,
#include <sys/socket.h> /* * @param sockfd 套接字描述符 * @param queue 请求等待的队列 * @return: 0正常,-1异常 */ int listen(int sockfd, int queue); |
在这个里面,queue这个参数需要解释一下。这个参数是标识最大的排队的客户socket的数目,在排队的客户socket有两种状态:1)正在链接,也就是正在三次握手;2)已经链接,也就是三次握手结束。下面的参考图,可以帮助理解这个现象:
4.6 函数accept
accept函数将等待队列中以链接好的socket去除,作为返回值返回,同时还可以得到客户端的地址结构,accept是会堵塞的,如果等待队列中没有准备好的socket,进程将会sleep直到有链接的socket,内核才会将进程唤醒。accept返回的socket与发起connect的socket对应,通过这个socket可以与客户端通讯。而accept的参数中的socket是listen socket,专门用于监听客户socket。一般而言,服务器始终保持一个监听socket。下面看看accept的接口描述:
#include <sys/socket.h> /* * @param sockfd: 监听socket,也就是调用了listen后的socket * @param saaddr: 客户socket地址,如果不关心,可以传NULL,但是len也必须NULL, 否则会有异常发生,有可能coredump,这点应该是可以保证的 * @param len: saaddr的长度,值-结果参数,如果为NULL,saaddr也必须为NULL */ int accept(int sockfd, struct sockaddr * saaddr, socklen &len ); |
4.7 函数fork和exec****
fork函数用于产生一个子进程,执行其他任务。初次使用时,估计比较难以理解,因为调用一次fork,却返回两次,一次在主进程,此时返回子进程ID;一次在子进程,此时返回0。所以,可以通过判断返回值来判断在子进程,还是在父进程,或是出错误。为什么需要这样设计呢,因为父进程可能产生很多子进程,这种使父进程可以方便的维护子进程的ID,而子进程可以通过调用getppid和getpid,方便的获取父进程的ID和本身的ID,返回0只是让程序可以判断当前实在子进程,可以执行子进程的逻辑。父进程在调用fork之前打开的file decriptor(例如socket, fd等等)均会在子进程中共享。下面看看如何fork函数的接口:
#include <uinstd.h> /* * 创建一个子进程,调用一次返回两次 * @return: 0 in child, non-negative id of child in parent, -1 for error */ pid_t fork() |
exec***系列函数有六个,其中有5个是封装了参数,最后还是调用了系统函数execve,可以看看下面的图示:
exec****调用的效果是,将当前进程上下文取代为参数中指定的应用程序,控制流重main开始。如果调用失败,控制流还是返回到调用进程。此函数只是用一个程序取代当前进程,而不是创建一个新进程。调用exec****之前的文件描述符任然是开着的,但是可以通过fcntl设置FD_CLOEXEC来关闭这些文件描述符。使用exec***后,应为程序会中main开始,而不是像fork一样从fork调用的地方开始,这样原来打开的fd无法获取,所以可以通过main参数的形式以字符串的形式传过来,因为socket其实只是一个整数。
4.8 并发服务器
前面几章中编写的服务器是非并发,每一次服务器只能处理一个客户端请求,其他客户端请求不得不在listen queue中排队,等待accept返回,如果多个客户端同时请求,并且服务器对一个请求处理的时间相对较长,listen queue很快会满,导致许多请求被抛弃。这一点是可以优化的,因为监听请求和处理请求这可以并行,并不矛盾,所以可以通过并发的方式处理请求,这样相比之前可以处理更多客户端链接,提高服务器处理能力。并发服务器可以使用多线程实现,也可以使用多进程实现。前者实现相对复杂,因为子线程core dump会引起父线程core dump,导致整个服务器宕机。而多进程而不同,子进程与父进程相对独立,子进程core dump不会影响父进程。下面的伪代码演示了一个典型的多进程并发服务器:
listenFd = Socket(…); Bind(listenFd,…); Listen(listenFd,…); while(true) { connFd = Accept(listenFd, …); if ((pid = fork()) == 0) // 子进程 { Close(listenFd); // 关闭监听fd,因为子进程不监听请求 Process(connFd); // 子进程处理请求 Close(connFd); // 使用完后,关闭connfd exit(0); } Close(connFd); // 父进程, 关闭confd不会立刻关闭,只会将引用计数减一 } …. |
上一章提到过,子进程会分享父进程调用fork之前的文件描述符(fd,socket等),那么你会看到父进程马上关闭了connFd,这样不会影响子进程吗?答案是不会,内核会为每一个文件描述符维护一个引用计数,当fork成功后,内核会为共享的fd添加引用计数的值,所以父进程调用Close只会使connFd的引用计数减一,而不会真正的关闭连接,影响子进程,但是如果父进程不关闭connfd,那么打开socket不会在子进程中“真正”关闭,这样socket使用完后没有被内核回收,最后达到文件描述符上限,无法正确Accept,而且更重要的是所有的链接都没有关闭,占用了机器的网络资源,并且会影响客户端那边的响应(这个可以试一试)。而在子进程中,其实可以不用调用close,因为listen只会打开一次,而且整个服务器程序只有在结束的时候才会释放,exit函数会自动释放该进程打开的所有的文件描述符,所以可以不用显示调用。子进程中显示的调用Close是一种良好的编程习惯,标识你没有忘记需要Close,释放资源。
4.9 函数close
close不经作用与socket,也作用与file,但是行为不一样,这里只描述针对socket的行为:
1) 标识不可用,即该socket再也不能用于connet,recv,send等操作
2) 将socket中没有送完的数据全部送出去,并执行4此tcp握手断开链接
3) 减少引用计数,如果该socket被多次打开
#include <unistd.h> /* * @return: 0 if ok, -1 if error */ int close (int sockfd); |
4.10 函数getsockname和getpeername
这两个函数用于获取链接的socket两端的地址结构,包括getsockname获取本机地址,getpeername获取对方地址,无论是服务器程序还是客户端程序都是这样。这里,使用name是可能有点歧义,因为其实返回的是地址结构(address structure),而不是name,下面来看看具体的接口描述:
#include <sys/socket.h> /* * @param sockfd: 如果connect成功的socket,可以得到内核任意分配的IP地址和端口,如果没有connect,但是调用了bind,同样可以通过此方法得到IP地址和端口,但是无论如何,调用此方法都是可以得到socket的family类型。此socket必须是accept返回的socket,而不是listen socket. * @param addptr: 地址结构 * @param len : in-out参数,输入时是addptr指向的数据结构长度,返回时是真正的数据长度 *return : 0 for ok, -1 for error */ int getsockname(int sockfd, struct sockaddr * addrptr, socklen_t len); /* * @param sockfd: 链接成功socket(可以尝试一下传入没有链接的socket,不过一般不会将没有链接的socket传进去) * @param * @param addptr: 地址结构 * @param len : in-out参数,输入时是addptr指向的数据结构长度,返回时是真正的数据长度 *return : 0 for ok, -1 for error */ int getpeername(int sockfd, struct sockaddr* addrptr, socklen_t* len); |
第五章 TCP客户端/服务器示例
5.1 章节简介
本章通过一个简单的echo服务器,介绍了TCP客户端/服务器模型。实现十分简单,主要是需要弄清楚一些边界情况以及如何处理,这些边界情况如下:
l 客户端/服务器启动时发生了什么?
l 客户端正常结束时发生了什么?
l 服务器进程在客户端完成之前停止会发生什么?
l 服务器崩溃时客户端会发生什么?
5.7 正常结束
僵尸进程:由于子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。那么不会因为父进程太忙来不及wait子进程,或者说不知道子进程什么时候结束,而丢失子进程结束时的状态信息呢? 不会。因为UNIX提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息,就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号process ID,退出状态termination status of the process,运行时间the amount of CPU time taken by the process等),直到父进程通过wait / waitpid来取时才释放。但这样就导致了问题,如果父进程不调用wait / waitpid的话,那么保留的那段信息就不会释放,其PID就会一直被占用,但是系统所能使用的PID是有限的,如果大量的产生 僵死进程,将因为没有可用的PID而导致系统不能产生新的进程。这个进程号没有被释放的进程,即为僵尸进程。僵尸进程的存在,是UNIX提供的一种父进程了解子进程结束的机制,使的父进程可以知道子进程是怎么“死的”,相当与子进程留下的“遗言”。只有父进程阅读(调用wait或waitpid)了该“遗言”,Unix才会将器释放。
正常开始流程
TCP正常连接需要3次握手,如上图所示,这里echo客户端发起链接:
l 客户端调用connect函数,发送SYN分节
l 服务器接到后发送SYN和ACK两个分节发给客户端
l 客户段接受后,connect函数返回,并发送一个ACK分节,socket处于ESATBLISHED状态
l 服务器接收到ACK分节后,accept返回。
PS: 服务器调用listen后,客户端既可以链接了,调用accept只是去除链接。
正常结束流程:
TCP正常断开需要进程四次握手,如上图所示,这里假设echo客户端发起的tcp结束:
l 客户端调用close(),发出一个FIN分节(segment)
l 服务器接受到FIN分节后,read函数返回0,结束读取循环,并发送一个ACK分节
l 客户端接受到ACK分节后,进入FIN_WAIT_2状态
l 服务器子进程exit时,关闭所有描述符,也就是调用close,引发最后两分节的收发
l 服务器发送FIN分节
l 客户端接受FIN分节并发送ACK分节响应
l 如此这样,TCP四次握手(4个分节的收发)完成,TCP断开
l 在这里,由于echo server没有调用wait函数,子进程“含冤而死”,变成僵尸进程
P.S.: 可以通过杀死僵死父进程而杀死所有的僵死进程
5.8 POSIX信号处理
当某件事情发生了,使用一个事件来告知进程,这个就是信号机制。信号有时又称之为“软件中断”(PS:信号signal和信号量semaphore是不同的)。信号是异步发生的,发生时可以通过回调函数执行指定操作。所以,进程是无法知道信号何时发生,它只知道信号发生了可以做些什么。信号的传递方式:
l A进程发给B进程(或自己)
l 内核发给进程
信号的三种处理方式,通过sigaction/signal函数注册信号处理函数
l 自定义回调函数,
void signal_hander(int signal_no)
PS: SIGKILL 和SIGSTOP 无法被捕获t
l 默认处理SIG_DFL
l 忽略处理SIG_IGN
sigaction()是POSIX的信号接口,而signal()是标准C的信号接口(如果程序必须在非POSIX系统上运行,那么就应该使用这个接口)
|
5.9 处理信号SIGCHLD
The purpose of the zombie state is to maintain information about the child for the parent to fetch at some later time. This information includes the process ID of the child, its termination status, and information on the resource utilization of the child (CPU time, memory, etc.). If a process terminates, and that process has children in the zombie state, the parent process ID of all the zombie children is set to 1 (the init process), which will inherit the children and clean them up (i.e., init will wait for them, which removes the zombie). Some Unix systems show the COMMAND column for a zombie process as <defunct>.
所以,通过上面的论述可以知道:如果子进程被kill后,父进程仍在,子进程变成僵尸进程,这是UNIX的机制,无法改变。这些僵尸进程占用内存,更为严重的是占用进程ID资源,这样导致后面无法fork,因为进程ID是有上限的。父进程需要自己清理这些僵尸进程,父进程可以通过注册SIGCHLD信号,这样可以在子进程被杀死时立刻收到通知,然后执行清理工作(调用wait或waitpid方法)。
处理被打断的系统调用(Handling Interrupted System Calls)
正如上面所说的,为了避免僵尸进程,需要通过注册SIGCHLD消息。消息触发,相当于发生了一次系统中断,如果此时进程在系统调用(如accept,write,read),这些系统调用就被打断。返回EINTR错误码(该错误码标识调用该函数时,被打断)。有些unix系统会自动重启该系统调用(如linux),而有些系统不会,所以如果你需要编写跨平台的程序时,需要处理EINTR错误吗,手动restart系统调用,如下面的实例代码:
for ( ; ; ) { clilen = sizeof (cliaddr); if ( (connfd = accept (listenfd, (SA *) &cliaddr, &clilen)) < 0) { if (errno == EINTR) continue; /* back to for () */ else err_sys ("accept error"); } |
5.9 wait和waitpid函数
unp中说道linux不会对信号排队,所以多个信号同时过来,会丢失信号,所以使用了下面的代码,来循环,尽可能的等待所有的僵死子进程。
void sig_chld(int signo) { pid_t pid; int stat; while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0) printf("child %d terminated\n", pid); return; } |
但是,这里有一点疑问,如果当主进程程序走到return这段代码,有一个进程结束并触发SIGCHLD信号,由于信号不排队,所以这个信号丢失了,那么任然会出现僵尸进程。
但是,从运行tcpserv04中,发现这个现象并没有出现。没有僵死进程。首先,我认为是同时结束的子进程太少,在第一次while轮训中,waitpid全部处理完。所以,我将tcpclit04中的5个子进程该成了100个。希望这样在while一次不能全部处理完。
实验证明,确实第一次while没有处理完,而是经过多次while,最后waitpid了全部的子进程,没有僵尸进程。
所以,我认为由于一次性结束的进程较多,在第一次while后,只执行了return就结束了当前的SIGCHLD事件处理函数,这样后面SIGCHLD事件就可以被监听到,只要一个被捕捉,通过while轮训,最后还是可以waitpid所有的结束进程的。所以,最后没有僵尸进程。
但是,我仍然想弄出一个出现僵尸进程的场景,我就将上面的代码修改如下:
void sig_chld(int signo) { pid_t pid; int stat; int iKilledCount = 0; while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0) { printf("child %d terminated\n", pid); ++iKilledCount; } sleep(3); // sleep 1 second so that it will not kill all zombie child printf("Total of Kill Zombie Child : %d\n", iKilledCount); return; } |
从上面的代码可以看出,在return之前,使主进程睡三秒,在这三秒中内,应该可以将其他所有的SIGCHLD事件全部掩盖掉(因为不排队)。这样,就可以出现僵死进程了,就可以证明这段代码还是存在缺陷。但是,经过实验证明,3秒过后,触发了第二次SIGCHLD事件,处理了所有的子进程,最后没有一个僵尸进程,:(。
ORZ,坑爹吗?不是信号不会排队吗,这里不是明显的排队了吗?
5.12 服务器进程结束(Termination of Server Process)
注意:这里是Termination of Server Process,不是Termination of Server Host。两种现象的处理方式不同。
模拟此现象十分简单
1 启动服务器tcpserv04
2 使用tcpcli01链接服务器
3 键入一句话,确认一切OK
4 找到与tcpcli01链接的服务器子进程,并杀掉
5 采用的tcpserv04服务器版本,所以可以正确的处理僵尸进程
6 服务器子进程结束后,会关掉socket fd,这样会发送一个FIN给客户端,并收到一个ACK。由于客户端正在堵塞在fgets方法,也就是等待用户输入,所以没有意识到tcp收到了FIN 并发送了ACK。
7 当用户输入了一段画并发送后,tcpcli01任然可以将这端话write给server,此时tcpserv04会直接返回一个RST分节给tcpcli01,标识socket fd已经关闭,此时tcpcli01的read会立刻返回,并报错。
这里的主要问题是:
tcpcli01其实处理了两个IO——one for network and one for user input。但是,程序程序确实线性的处理这两个IO,当堵塞在user IO时,network IO发生了事情,进程也不会立刻察觉。这也就是为什么需要使用select和poll方法,引入IO多路归并,这样可以同时监听多个IO的状态变化,做到立刻反馈。
5.13 信号SIGPIPE
进程可以向收到FIN分节的socket写入数据,导致另外一端发送RST分节,此时,进程如果向已经收到了RST分节的socket写入,会导致SIGPIPE信号触发(像一个已经关闭的socket写入数据会导致SIGPIPE)。该信号的默认行为是结束进程,但是你可以通过忽略该信号,或者注册SIGPIPE信号处理函数并返回,这样就可以避免进程结束。如果你这样做了,当调用write时,会返回EPIPE。此时,你知道的事实是:对方已经发送了一个RST分节,你不能在发送任何数据。知道这些信息后,你可以根据这些信息,处理你的业务逻辑。
5.15 服务器crash后重启
如标题,如果客户端在不知道情的情况下,服务器crash后重启,由于之前的socket信息全部丢失,服务器的TCP会返回一个RST分节,标识socket,关闭,此时,客户端调用write会返回错误码ECONNRESET。所以,客户端需要有一些逻辑处理服务器重启。
5.16 服务器关机
当服务器关闭时,init线程会给所有其他线程发送一个SIGTERM信号,然后过5到10秒,发送SIGKILL信号(此信号无法注册处理事件,必杀线程),所以每个进程有5到10秒的时间善后,然后被信号SIGKILL”一击必杀”。
一点感悟(2011-11-19)
目前读到这里(第五章结束),发现Linux内核其实为我们完成了许多异步的事情,第一个是listen函数。调用它之后,内核会为我们(这里指客户进程)异步的管理其他接入的链接。第二个是信号处理,内核为我们管理信号的发生,发送和信号处理函数调用。第三个是TCP处理,内核帮我们完成了重传,RST分节的的收发,我们用户进程并不知晓。所以,其实处理多用户并发请求,并不是一定要大量使用多线程或多进程,因为内核其实我们做了这些工作,我们要做的是高效的处理应用程序业务逻辑,合理的利用内核为我们提供的这些异步机制。
上面的论点可能存在错误,希望通过后面的学习,在回过头来看这个感悟时,我成长了。
第六章 I/O多路归并,poll和select函数
6.2 I/O模型
下面概念的区别(参见http://www.cppblog.com/converse/archive/2009/05/13/82879.html):
l 同步/异步:被通知
l 阻塞/非阻塞:不被通知,自己询问
上面的概念比较学院派,其实最主要的还是需要清除几种I/O模型的执行原理,然后能够正确高效的在设计应用程序时选取最适合的模型,这才是王道…
Unix提供的5种I/O模型
l 堵塞(Block I/O)
l 非堵塞(Nonblocking I/O)
l I/O多路归并(I/O Multiplexing) ----select and poll
l 信号驱动I/O(Signal Driven I/O) ----SIGIO
l 异步I/O(Asynchronous I/O)----this POSIX aio_ functions
输入操作的两个阶段
l 等待数据(socket就是数据到达,文件就是磁盘将数据加载到内存)
l 将数据重内核拷贝到应用程序进程
1 Block I/O Model
从上面的截图,可以看出来,堵塞I/O模型中,进程调用recvfrom后,被堵塞,数据到达,并且拷贝到进程后,进程恢复,处理数据。输入截断的两个过程全部被堵塞了,无法做任何事情,这个时候进程被内核sleep了。如果在这个过程,进程被信号打断,有些unix系统不会重启该过程,而返回错误码,进程需要自己重启这个过程。但是,有些unix系统会重启该过程,应用程序不用关心被打断后,重启该系统调用。
2 Nonblocking I/O Model
从上图可以看出,非堵塞的I/O模型中,recvfrom调用后,如果数据没有准备好,会立刻返回一个错误码,标识数据没有达到。一般的应用程序,可以在一个while循环中不断调用recvfrom,不端询问,知道数据准备好,将数据读取出来。并且在轮训的间隙中,可以做一些其他的事情,这样也不会浪费CPU。CPU使用效率比同步IO模型高。
3 I/O Multiplexing Model
I/O多路归并结合了堵塞和非堵塞模型(可以设定),并且添加了多个I/O监控的特性,省了应用程序轮训多个IO的操作,而且这些事情是内核完成,效率和质量上应该更有保证。
(还有一种模型类似此模型,就是使用多线程机制,每个线程调用堵塞的IO模型,这样就可以实现同时监听多个IO,但是引入多线程就有可能会引如使用多线程的麻烦,如线程同步,线程通信等等)
4 Signal Driven I/O Model
相比于非堵塞I/O,信号I/O省去了轮训的过程,当数据准备好后,在回调函数中,将数据拷贝到进程,并处理数据。但是,只能监听一个IO。
5 Asynchronous I/O Model
与信号IO类似,通过回调函数处理数据,唯一不同的是,但处理数据时,该数据已经在进程中了,而信号IO调哟昂回调函数时,数据还在内核中。异步IO的效率据说比信号IO高。
以上5种I/O模型的比较
前面四种均是在数据准备好后,才能处理。只有一边IO是在数据拷贝到进程后,才处理数据。根据POSIX定义的同步与异步模型:
POSIX defines these two terms as follows: · A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes. · An asynchronous I/O operation does not cause the requesting process to be blocked. Using these definitions, the first four I/O models—blocking, nonblocking, I/O multiplexing, and signal-driven I/O—are all synchronous because the actual I/O operation (recvfrom) blocks the process. Only the asynchronous I/O model matches the asynchronous I/O definition. |
只有异步IO属于异步,前面始终全部属于同步。
6.4 函数str_cli
unpv13e/select/tcpcli01.c 中有个bug,链接的端口是“7”而不是SERV_PORT(9877)。
修改后,重新连接,发现,一旦杀死服务器链接继承,相比于5.12节的例子,客户端会立刻响应,并输出响应的错误信息。这一点是十分有意义的,因为在某些应用中,如果用户输入了很多,花费了很多时间,可是在输入过程中,链接断开,客户端不能即时响应,当用户输入完毕并提交到服务器后,发现无法连接,这样会很伤害用户感情的。所以,I/O多路归并在某些场合十分有用的。但是如果只有一个IO FD,感觉用I/O multiplexing没有多大意义。
6.6 函数shutdown
此函数功能与close类似,但是可以更细节的控制close的行为,主要不同如下:
l 引用计数:close会降低引用计数,一但为0,才会关闭socket,而shutdown不会考虑引用计数,一旦调用立刻开启结束的四次握手
l 单方关闭:shutdown提供参数进行单向关闭,比如关闭read或关闭write或全部关闭,通过SHUT_RD,SHUT_WR和SHUT_RDWR来指定。
shutdown函数
函数原型如下:
#include <sys/socket.h> int shutdown(int sockfd, int howto); Returns:0 if OK, -1 on error |
howto参数可以为下面之中的一个
SHUT_RD socket读的一半链接被关闭,具体现象:调用该函数后,不能接受任何数据,任何处于接受buffer中的数据将被丢弃。函数调用之后,进程不能再正对该socket调用任何“读”相关的函数。 SHUT_WR socket写的一半链接被关闭,此现象也称之为“半关闭”(half-close,注意只针对write half close),具体现象:任何写buffer的数据将被发送,发送完后,将发送一个FIN分节。正如上面提到的,socket的应用计数不减一。任何“写”相关的操作将不能作用于该socket。 SHUT_WRRD 相当于第一次调用“SHUT_RD”,然后调用“SHUT_WR”。 |
从上面的描述,可以看出,“关闭读一半”与“关闭写一半”还不能完全等价。
6.8 Tcp回射服务器
本节通过使用select的IO多路复用方式,使用单线程实现了回射服务器,但是发现单线程的实现会遭受Denial-of-Service攻击,因为处理echo是通过堵塞的方式处理,恶意用户可以发送单字节数据永远堵塞服务器进程,其他用户就无法使用服务了。所以,要么使用异步,要么采用多线(进)程,才能解决此问题。
6.10 Poll函数
poll函数的接口比select好用,因为使用select时,需要考虑系统的最大file descritor与fd max size的大小,而且如果fd数据很大,即时只有一个fd,貌似也要检测所有的借点,这样是不有点效率不高。但是poll却不同,poll通过pollfd这样一个数据结构的数组来维护需要检查数据,所以设计得更好用,更合理一点。而且,使用pollfd,中的revents是out参数,events是in参数,不容易使用错误,不像select,fdset是一个in-out参数。
第七章 Socket可选项
7.2 函数‘getsockopt’和‘setsockopt’
这两个函数的用途用来设置或读取socket选项,函数接口如下:
#include <sys/socket.h> int getsockopt(int sockfd, int level, int optname, void* optval, socklen_t *optlen); int setsockopt(int sockfd, int level, int opnname, const void* optval, socklen_t optlen); Both return: 0 if OK, -1 on error |
参数简单描述一下:
l sockfd: 被设置的socket file descriptor
l level: level specifies the code in the system that interprets the option: the general socket code or some protocol-specific code (e.g., IPv4, IPv6, TCP, or SCTP).具体值,见下表。
l optname: 选项常量,见下表
l optval: 被设置的值或读取值
l optlen: 上面数据结构的大小
7.3 检测socket选项是否支持并获取默认值
这一节中学习到的最大技巧是c/c++可以这么简介的初始化结构体,这样就可以轻松的编写数据驱动的测试用例,如下为代码片段:
struct StartEndWithTestInfo { string m_sText; string m_sFlag; BOOL m_bWith; } arrStartWithTestData[] ={ /** * 空字符测试 */ {"", "", M_TRUE}, {"some text start with", "", M_TRUE}, {"", "dfs", M_FALSE}, /** * 成功的测试 */ {"some text start with", "some", M_TRUE}, {"some", "some", M_TRUE}, /** * 失败的测试 */ {"some text start with", "slfjjl", M_FALSE}, {"some", "some longger", M_FALSE} }, arrEndWithTestData[] = { /** * 空字符测试 */ {"", "", M_TRUE}, {"some text start with", "", M_TRUE}, {"", "dfs", M_FALSE}, /** * 成功的测试 */ {"some text start with", "with", M_TRUE}, {"some", "some", M_TRUE}, /** * 失败的测试 */ {"some text start with", "wsfsfith", M_FALSE}, {"some", "some longger", M_FALSE}, }; |
可以看到,通过成员定义的顺序,在大括号中初始化成员,great!
7.4 通用的socket选项
The basic principle here is that a successful return from close, with the SO_LINGER socket option set, only tells us that the data we sent (and our FIN) have been acknowledged by the peer TCP. This does not tell us whether the peer application has read the data. If we do not set theSO_LINGER socket option, we do not know whether the peer TCP has acknowledged the data.
One way for the client to know that the server has read its data is to call shutdown (with a second argument of SHUT_WR) instead of close and wait for the peer to close its end of the connection. We show this scenario in Figure 7.10.
调用close的原则:close返回0,只能标识对方tcp接受到了完整的数据,但是不能保证应用程序进程获得了所有的数据,这样有可能应用程序进程在收到数据之前,就死掉了。
有一种方式可以保证客户端知道服务器程序是否接受了最后的数据,调用shutdown。
7.11 函数fcntl
此函数用于设置socket或其他文件描述的属性,比如可以将socket设置为non-blocking或者是signal IO。此函数的接口如下:
#include <fcntl.h> int fcntl(int fd, int cmd, … /* int arg */); return : depends on cmd if ok, -1 on error |
典型的用法是先去处原来的设置,然后通过逻辑或将需要的设置添加上去,而不是直接设置需要的值:
正确的做法
int flags; /* Set a socket as nonblocking */ if ( (flags = fcntl (fd, F_GETFL, 0)) < 0) err_sys("F_GETFL error"); flags |= O_NONBLOCK; if (fcntl(fd, F_SETFL, flags) < 0) err_sys("F_SETFL error"); |
错误的做法:
/* Wrong way to set a socket as nonblocking */ if (fcntl(fd, F_SETFL, O_NONBLOCK) < 0) err_sys("F_SETFL error"); |
第八章 UDP套接字基础
8.2函数recvfrom和sendto
这两个函数与tcp套接字的recv/send相似,但是多了一些参数,可以用下面的比喻来加深理解:
recvfrom = recv + accpet sendto = send + connect |
下面看看具体的函数接口:
#include <sys/socket.h> /** * @param flags 配置,如果不关心,可以设置为0 * @param from [out] 客户socket的地址 * @param addrlen [in-out] 客户端地址的长度 * from 与 addrlen同时为NULL时,表明不关系客户端地址 */ ssize_t recvfrom(int sock, void* buff, size_t nbytes, int flags, struct sockaddr* from, socklen_t* addrlen); /** * 与上面的类似,更具函数名称自己可以推断 */ ssize_t sendto(int sock, const void* buf, size_t nybtes, int flags, const struct sockaddr* to, sockelen_t addrlen); Both Return: number of data recv or send if OK, or -1 on error |
8.8 认证接受的响应
由于UDP没有链接,所以client接收一个来自其他server的datagram,因为只要任何一个server知道了client的临时port,都可以向他发包。所以,可以通过recvfrom的最后两个参数,用于和发送地址比对,如果相同,则说明是服务器发送过来的,否则不是。但是,这仍然有一个问题,那就是server的IP使用通配符绑定的,如果一个server有多个网卡时,那么就有可能通过不同的IP来发送echo,那么client的这种策略就失效了,会出现误判。
8.9 不运行服务器
如果在运行服务器的情况下,直接运行客户端,会发生什么情况呢?
客户端会永远停留在recefrom上。因为udp发送了数据之后,成功返回,并不知道包是否已近到达了目的地,所以及时没有到,客户端也不知道。但是,ICMP会发送一个异步的error。这个error是不会通知给客户端的,必须通过调用connect,才能显示的被通知。
8.11 UDP的connect函数
connect函数对于udp socket,不会有三次握手,更不会建立链接,但是与不掉用connect的udp socket有下面三点不同:
l 发送数据不需要使用sendto,而是write,send或sendmsg,地址不需要传
l 接受数据不需要使用recvfrom,而是read,recv或recvmsg,地址不需要(可以避免误接受)
l 可以同步接受异步错误
好处
l 数据包不会发错,也就是上面8.8节的问题不会出现,因为datagram中记录了两端的IP和PORT,所以UDP可以区分,
l 编写程序时,不需要总是传输重复的目标IP和端口
l 效率变高,目标地址和端口只需要一次拷贝,
8.13 UDP没有流量控制机制
UDP没有流浪控制机制,也就是说,如果一个客户端发包速度过快,UDP socket的缓存装满后,多的包会被UDP丢弃掉。但是可以通过设置第七章谈到的socket receiving buffer,加大缓存大小,这样可以稍微缓解丢包,但是不可能重根本上避免丢包现象。
第十三章 守护进程和超级服务器inetd
13.1 介绍
守护进程没有与终端关联,默默的在后台运行。这样就不会被终端打扰(也就是用户),如关闭终端时会自动关闭该终端开启的非守护进程的相关程序。
13.5 守护进程“inetd”
inetd是一个通用的并行服务器,为每个服务器程序处理了并行(fork,daemon)和网络(socket,bind,accept),并且将socket通信重定向到fd 0,1,2,这样编写服务器程序就想编写一般的程序一样,只是输入和输出都是与远端的socket通讯。inetd有点像是一个服务器‘容器’,只是功能过于简单。
第十四章 高进I/O函数
14.2 Sockets超时
三种方式设置socket超时
1. 信号SIGALARM:通过signal函数,注册SIGALRM信号处理函数,通过alarm函数,设定超时。由于使用信号机制,不建议在多线程环境下使用,因为会十分复杂。
2. Select函数:调用select函数
3. Socket选项SO_RCVTIMEO 和 SO_SNDTIMEO:每个fd只需要设置一次,但是有点平台不兼容,不是每个平台至此这个选项
第十五章 Unix Domain协议
15.1 介绍
Unix Domain协议是一个特殊的IP/TCP协议,用于本地服务区客户端通过网络API通讯,可以作为IPC的一种方式,但是效率传统的IP/TCP的2倍,同时它可以传输fd,并且有额外的安全检测。
本文转自bourneli博客园博客,原文链接:http://www.cnblogs.com/bourneli/archive/2012/11/08/2760988.html,如需转载请自行联系原作者