[网络编程]TCP/IP网络编程
文章目录
基于TCP的服务端/客户端
类比 打电话
TCP / IP协议栈
可以理解为数据收发分成 4 个层次化过程。
TCP和UDP是以IP层提供的路径信息为基础 完成实际的数据传输,故该层称为传输层。
TCP可以保证数据的可靠传输,但是它发送数据是以 IP层为基础(这是协议栈层次化的原因)。
实现基于TCP的服务端 / 客户端
-
TCP服务端的默认函数的调用顺序
说明:服务器端首先创建的套接字并非 真正的服务端套接字, 调用listen函数时的套接字才是服务端套接字。(我的理解是只有调用了bind()和listen()才能确定是服务端,所以此套接字才能确定是服务端套接字。如果直接调用connect()就是客户端套接字。)
-
TCP客户端的默认函数的调用顺序
与服务端相比,区别就在于**「请求连接」**,他是创建客户端套接字后向服务端发起的连接请求。服务端调用 listen 函数后创建连接请求等待队列,之后客户端即可请求连接。
客户端调用 connect 函数候,发生以下函数之一才会返回(完成函数调用):
-
服务端接受连接请求
-
发生断网等一场状况而中断连接请求
注意:接受连接不代表服务端调用 accept 函数,其实只是服务器端把连接请求信息记录到等待队列。 因此 connect 函数返回后并不应该立即进行数据交换。
基于 TCP 的服务端/客户端函数调用关系
TCP客户端套接字在调用connect函数时自动分配IP地址和端口号。
-
实现迭代服务端 / 客户端
调用accept函数后,紧接着调用 I/O 相关的 read write 函数,然后调用 close 函数。这并非针对服务器套接字,而是针对 accept 函数调用时创建的套接字。
TCP原理
-
TCP套接字的I/O套件:
-
write函数调用瞬间,数据将移至输出缓冲;read函数调用瞬间,从输入缓冲读取数据。
-
I/O缓冲特性如下:
- I/O缓冲在每个TCP套接字中单独存在;
- I/O缓冲在 创建套接字时 自动生成;
- 即使关闭套接字也会继续传递输出缓冲中遗留的数据;
- 关闭套接字将丢失输入缓冲中的数据。
-
TCP内部工作原理:与对方套接字的连接
- 三次握手
- 套接字是 全双工方式工作的,即 可以 双向传递数据。
-
TCP内部工作原理:与对方主机的数据交换
-
ACK号 = SEQ号 + 传递的字节数 + 1;
超时重传:
-
-
TCP内部工作原理:断开套接字的连接
- 图中数据包内的 FIN 表示断开连接。 也就是 双方各发送一次FIN消息后断开连接。 此过程经历4个阶段,因此称为 四次握手。 图中主机B传递了 两次ACK 5001,第二次FIN数据包中的ACK 5001只是因为接受了 ACK消息后未接收到的数据重传的。
- TCP套接字连接设置的三次握手过程。尤其是3次数据交换过程每次收发的数据内容。
三次握手主要分为:
- 与对方套接字 建立连接;
- 与对方套接字进行 数据交换;
- 断开与对方套接字的连接。
每次收发的数据内容主要有:
- 由主机1给主机2发送初始的SEQ,首次连接请求的关键字是SYN,表示 收发数据前同步传输的消息;
- 主机2收到报文以后,给主机 1 传递信息,用一个新的SEQ表示自己的序号,然后ACK代表已经接受到主机1的消息,希望接受下一个消息;
- 主机1收到主机2的确认以后,还需要给主机2给出确认,此时再发送一次SEQ和ACK。
基于UDP的服务端/客户端
类比 寄信
UDP是一种不可靠的数据传输方式。TCP 的生命在于流控制。
-
UDP的工作原理
IP 的作用就是让离开主机 B 的 UDP 数据包准确传递到主机 A 。但是把 UDP 数据包最终交给主机 A 的某一 UDP 套接字的过程是由 UDP 完成的。UDP 的最重要的作用就是根据端口号将传到主机的数据包交付给最终的 UDP 套接字。
-
UDP的高效使用
TCP比UDP慢的原因有:
- 收发数据前后进行的连接设置及清楚过程;
- 收发过程中为保证可靠性而添加的流控制。
如果收发的数据量小但是需要频繁连接时,UDP比TCP更高效。
-
-
实现基于 UDP 的服务端/客户端
-
UDP中的服务端和客户端没有连接
-
UDP服务器和客户端均只需一个套接字
-
基于UDP的数据I/O函数
-
TCP套接字保持与对方套接字的连接,所以传输数据时无需加上地址信息。但 **UDP套接字不会保持连接状态(UDP套接字只有简单的油筒功能)。**因此每次传输数据都需添加目标地址信息(相当于寄信在信件中填写地址)。
发送UDP数据的函数:
1 2 3 4 5 6 7 8 9 10 11 12
#include <sys/socket.h> ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen); /* 成功时返回传输的字节数,失败是返回 -1 sock: 用于传输数据的 UDP 套接字 buff: 保存待传输数据的缓冲地址值 nbytes: 待传输的数据长度,以字节为单位 flags: 可选项参数,若没有则传递 0 to: 存有目标地址的 sockaddr 结构体变量的地址值 【与TCP输出函数最大的区别】 addrlen: 传递给参数 to 的地址值结构体变量长度 */
接受UDP数据的函数:
1 2 3 4 5 6 7 8 9 10 11 12
#include <sys/socket.h> ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen); /* 成功时返回传输的字节数,失败是返回 -1 sock: 用于传输数据的 UDP 套接字 buff: 保存待传输数据的缓冲地址值 nbytes: 待传输的数据长度,以字节为单位 flags: 可选项参数,若没有则传递 0 from: 存有发送端地址信息的 sockaddr 结构体变量的地址值 addrlen: 保存参数 from 的结构体变量长度的变量地址值。 */
-
UDP客户端套接字的地址分配
UDP 程序中,调用 sendto 函数传输数据前应该完成对套接字的地址分配工作,因此调用 bind 函数。当然,bind 函数在 TCP 程序中出现过,但 bind 函数不区分 TCP 和 UDP,也就是说,在 UDP 程序中同样可以调用。另外,如果调用 sendto 函数尚未分配地址信息,则在首次调用 sendto 函数时给相应套接字自动分配 IP 和端口。而且此时分配的地址一直保留到程序结束为止,因此也可以用来和其他UDP 套接字进行数据交换。当然,IP 用主机IP,端口号用未选用的任意端口号
调用 sendto 函数时自动分配IP和端口号,因此,UDP 客户端中通常无需额外的地址分配过程。所以之前的示例中省略了该过程。这也是普遍的实现方式。
-
-
-
UDP 的数据传输特性和调用 connect 函数
-
存在数据边界的 UDP 套接字
TCP数据传输中不存在数据边界,这表示「数据传输过程中调用 I/O 函数的次数不具有任何意义」
相反,UDP 是具有数据边界的下一,传输中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数和输出函数的调用次数完全一致,这样才能保证接收全部已经发送的数据。
UDP 通信过程中 I/O 的调用次数必须保持一致。
-
已连接(connect)UDP 套接字与未连接(unconnected)UDP 套接字
TCP 套接字中需注册待传传输数据的目标IP和端口号,而在 UDP 中无需注册。因此通过 sendto 函数传输数据的过程大概可以分为以下 3 个阶段:
- 第 1 阶段:向 UDP 套接字注册目标 IP 和端口号;
- 第 2 阶段:传输数据;
- 第 3 阶段:删除 UDP 套接字中注册的目标地址信息。
每次调用 sendto 函数时重复上述过程。每次都变更目标地址,因此可以重复利用同一 UDP 套接字向不同目标传递数据。这种未注册目标地址信息的套接字称为未连接套接字,反之,注册了目标地址的套接字称为连接 connected 套接字。显然,UDP 套接字默认属于未连接套接字。
当一台主机向另一台主机传输很多信息时,上述的三个阶段中,第一个阶段和第三个阶段占整个通信过程中近三分之一的时间,缩短这部分的时间将会大大提高整体性能。
-
创建已连接 UDP 套接字
创建已连接 UDP 套接字过程格外简单,只需针对 UDP 套接字调用 connect 函数。
1 2 3 4 5 6
sock = socket(PF_INET, SOCK_DGRAM, 0); memset(&adr, 0, sizeof(adr)); adr.sin_family = AF_INET; adr.sin_addr.s_addr = inet_addr(argv[1]); adr.sin_port = htons(atoi(argv[2])); connect(sock, (struct sockaddr *)&adr, sizeof(adr));
针对 UDP 调用 connect 函数并不是意味着要与对方 UDP 套接字连接,这只是向 UDP 套接字注册目标IP和端口信息。
之后就与 TCP 套接字一致,每次调用 sendto 函数时只需传递信息数据。因为已经指定了收发对象,所以不仅可以使用 sendto、recvfrom 函数,还可以使用 write、read 函数进行通信。
-
优雅的断开套接字的连接
之前用的方法不够优雅是因为,我们是调用 close 函数或closesocket 函数单方面断开连接的。
-
基于 TCP 的半关闭
TCP断开连接过程比建立连接更重要,因为连接过程中一般不会出现大问题,但是断开过程可能发生预想不到的情况。因此应该准确掌控。所以要掌握半关闭(Half-close),才能明确断开过程。
-
单方面断开连接带来的问题
Linux 和 Windows 的 closesocket 函数意味着完全断开连接。完全断开不仅指无法传输数据,而且也不能接收数据。因此在某些情况下,通信一方单方面的断开套接字连接,显得不太优雅。
图中描述的是 2 台主机正在进行双向通信,主机 A 发送完最后的数据后,调用 close 函数断开了最后的连接,之后主机 A 无法再接受主机 B 传输的数据。实际上,是完全无法调用与接受数据相关的函数。最终,由主机 B 传输的、主机 A 必须要接受的数据也销毁了。
为了解决这类问题,**「只关闭一部分数据交换中使用的流」**的方法应运而生。断开一部分连接是指,可以传输数据但是无法接收,或可以接受数据但无法传输。顾名思义就是只关闭流的一半。
-
套接字和流(Stream)
两台主机通过套接字建立连接后进入可交换数据的状态,又称「流形成的状态」。也就是把建立套接字后可交换数据的状态看作一种流。
一旦两台主机之间建立了套接字连接,每个主机就会拥有单独的输入流和输出流。当然,其中一个主机的输入流与另一个主机的输出流相连,而输出流则与另一个主机的输入流相连。另外,本章讨论的「优雅的断开连接方式」只断开其中 1 个流,而非同时断开两个流。Linux 和 Windows 的 closesocket 函数将同时断开这两个流,因此与「优雅」二字还有一段距离。
-
针对优雅断开的 shutdown 函数
shutdown 用来关闭其中一个流:
1 2 3 4 5 6 7
#include <sys/socket.h> int shutdown(int sock, int howto); /* 成功时返回 0 ,失败时返回 -1 sock: 需要断开套接字文件描述符 howto: 传递断开方式信息 */
调用上述函数时,第二个参数决定断开连接的方式,其值如下所示:
- SHUT_RD : 断开输入流
- SHUT_WR : 断开输出流
- SHUT_RDWR : 同时断开 I/O 流
**若向 shutdown 的第二个参数传递SHUT_RD ,则断开输入流,套接字无法接收数据。即使输入缓冲收到数据也会抹去,而且无法调用相关函数。如果向 shutdown 的第二个参数传递SHUT_WR ,则中断输出流,也就无法传输数据。若如果输出缓冲中还有未传输的数据,则将传递给目标主机。最后,若传递关键字SHUT_RDWR ,则同时中断 I/O 流。**这相当于分 2 次调用 shutdown ,其中一次以SHUT_RD 为参数,另一次以SHUT_WR 为参数。
-
为何要半关闭?
考虑以下情况:
一旦客户端连接到服务器,服务器将约定的文件传输给客户端,客户端收到后发送字符串「Thank you」给服务器端。
此处「Thank you」的传递是多余的,这只是用来模拟客户端断开连接前还有数据要传输的情况。此时程序的还嫌难度并不小,因为传输文件的服务器端只需连续传输文件数据即可,而客户端无法知道需要接收数据到何时。客户端也没办法无休止的调用输入函数,因为这有可能导致程序阻塞。
是否可以让服务器和客户端约定一个代表文件尾的字符?
这种方式也有问题,因为这意味这文件中不能有与约定字符相同的内容。为了解决该问题,服务端应最后向客户端传递 EOF 表示文件传输结束。客户端通过函数返回值接受 EOF ,这样可以避免与文件内容冲突。那么问题来了,服务端如何传递 EOF ?
断开输出流时向主机传输 EOF。
当然,调用 close 函数的同时关闭 I/O 流,这样也会向对方发送 EOF 。但此时无法再接受对方传输的数据。换言之,若调用 close 函数关闭流,就无法接受客户端最后发送的字符串「Thank you」。这时需要调用 shutdown 函数,只关闭服务器的输出流。这样既可以发送 EOF ,同时又保留了输入流。下面实现收发文件的服务器端/客户端。
-
基于半关闭的文件传输程序
上述文件传输服务器端和客户端的数据流可以整理如图:
-
域名及网络地址
-
域名系统
-
DNS 是对IP地址和域名进行相互转换的系统,其核心是 DNS 服务器。
域名就是我们常常在地址栏里面输入的地址,将比较难记忆的IP地址变成人类容易理解的信息。
-
DNS服务器
相当于一个字典,可以查询出某一个域名对应的IP地址
-
-
-
IP地址和域名之间的转换
域名的必要性:因为IP地址可能经常改变,而且也不容易记忆,通过域名可以随时更改解析,达到更换IP的目的
-
利用域名获取IP地址
1 2 3 4 5
#include <netdb.h> struct hostent *gethostbyname(const char *hostname); /* 成功时返回 hostent 结构体地址,失败时返回 NULL 指针 */
返回时 地址信息装入hostent结构体:
1 2 3 4 5 6 7 8 9 10 11 12
struct hostent { char *h_name; /* Official name of host. */ char **h_aliases; /* Alias list.可以通过多个域名访问同一主页。同一IP可以绑定多个域名 */ int h_addrtype; /* Host address type. */ int h_length; /* Length of address. */ char **h_addr_list; /* List of addresses from name server. */ /* h_addr_list: 这个是最重要的的成员。通过此变量以整数形式保存域名相对应的IP地址。另外,用 户比较多的网站有可能分配多个IP地址给同一个域名,利用多个服务器做负载均衡。此时可以通 过此变量获取IP地址信息。*/ };
调用 gethostbyname 函数后,返回的结构体变量如图所示:
-
利用IP地址获取域名
1 2 3 4 5 6 7 8 9
#include <netdb.h> struct hostent *gethostbyaddr(const char *addr, socklen_t len, int family); /* 成功时返回 hostent 结构体变量地址值,失败时返回 NULL 指针 addr: 含有IP地址信息的 in_addr 结构体指针。为了同时传递 IPV4 地址之外的全部信息,该变量 的类型声明为 char 指针 len: 向第一个参数传递的地址信息的字节数,IPV4时为 4 ,IPV6 时为16. family: 传递地址族信息,ipv4 是 AF_INET ,IPV6是 AF_INET6 */
-
多进程服务器端
进程概念及应用
-
并发服务端的实现方法
通过改进服务端,使其同时向所有发起请求的客户端提供服务,以提高平均满意度。而且,网络程序中数据通信时间比 CPU 运算时间占比更大,因此,向多个客户端提供服务是一种有效的利用 CPU 的方式。
具有代表性的并发服务端的实现模型和方法:
- 多进程服务器:通过创建多个进程提供服务;
- 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务;
- 多线程服务器:通过生成与客户端等量的线程提供服务。
-
理解进程
进程定义: 占用内存空间的正在运行的程序
-
进程 ID
在说进程创建方法之前,先要简要说明进程 ID。无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为「进程ID」,其值为大于 2 的证书。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1。接下来观察在 Linux 中运行的进程。
1
ps au # 该命令同时列出了 PID(进程ID)。参数 a 和 u列出了所有进程的详细信息。
-
通过调用 fork 函数创建进程
1 2 3
#include <unistd.h> pid_t fork(void); // 成功时返回进程ID,失败时返回 -1
fork 函数将创建调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用 fork 函数的进程。另外,两个进程都执行 fork 函数调用后的语句(准确的说是在 fork 函数返回后)。但因为是通过同一个进程、复制相同的内存空间,之后的程序流要根据 fork 函数的返回值加以区分。即利用 fork 函数的如下特点区分程序执行流程。
- 父进程:fork 函数返回子进程 ID
- 子进程:fork 函数返回 0
从图中可以看出,父进程调用 fork 函数的同时复制出子进程,并分别得到 fork 函数的返回值。但复制 前,父进程将全局变量 gval 增加到 11,将局部变量 lval 的值增加到 25,因此在这种状态下完成进程复 制。复制完成后根据 fork 函数的返回类型区分父子进程。父进程的 lval 的值增加 1 ,但这不会影响子 进程的 lval 值。同样子进程将 gval 的值增加 1 也不会影响到父进程的 gval 。因为 fork 函数调用后分 成了完全不同的进程,只是二者共享同一段代码而已。
例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
#include <stdio.h> #include <unistd.h> int gval = 10; int main(int argc, char *argv[]) { pid_t pid; int lval = 20; gval++, lval += 5; pid = fork(); if (pid == 0)//子进程,复制的子进程 gval += 2, lval += 2; else gval -= 2, lval -= 2; if (pid == 0) printf("Child Proc: [%d,%d] \n", gval, lval); else printf("Parent Proc: [%d,%d] \n", gval, lval); return 0; }
编译运行:
1 2
gcc fork.c -o fork ./fork
运行结果:
可以看出,当执行了 fork 函数之后,此后就相当于有了两个程序在执行代码,对于父进程来说,fork函数返回的是子进程的ID,对于子进程来说,fork 函数返回 0。所以这两个变量,父进程进行了 +2 操作 ,而子进程进行了 -2 操作,所以结果是这样。
进程和僵尸进程
文件操作中,关闭文件和打开文件同等重要。同样,进程销毁和进程创建也同等重要。如果未认真对待进程销毁,他们将变成僵尸进程。
-
僵尸(Zombie)进程
进程的工作完成后(执行完 main 函数中的程序后)应被销毁,但有时这些进程将变成僵尸进程,占用 系统中的重要资源。这种状态下的进程称作「僵尸进程」,这也是给系统带来负担的原因之一。
僵尸进程是当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此 时子进程将成为一个僵尸进程。如果父进程先退出 ,子进程被init接管,子进程退出后init会回收 其占用的相关资源
-
产生僵尸进程的原因
为了防止僵尸进程产生,先解释产生僵尸进程的原因。利用如下两个示例展示调用 fork 函数产生子进程的终止方式。
- 传递参数并调用 exit() 函数
- main 函数中执行 return 语句并返回值
向 exit 函数传递的参数值和 main 函数的 return 语句返回的值都回传递给操作系统。而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。也就是说将子进程变成僵尸进程的正是操作系统。既然如此,僵尸进程何时被销毁呢?
应该向创建子进程的父进程传递子进程的 exit 参数值或 return 语句的返回值。
如何向父进程传递这些值呢?操作系统不会主动把这些值传递给父进程。只有父进程主动发起请求(函数调用)的时候,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。
也就是说,父母要负责收回自己生的孩子。
创建僵尸进程的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
#include <stdio.h> #include <unistd.h> int main(int argc, char *argv[]) { pid_t pid = fork(); if (pid == 0) { puts("Hi, I am a child Process"); } else { printf("Child Process ID: %d \n", pid); sleep(30); } if (pid == 0) puts("End child proess"); else puts("End parent process"); return 0; }
编译运行:
1 2
gcc zombie.c -o zombie ./zombie
结果:
因为暂停了 30 秒,所以在这个时间内可以验证一下子进程是否为僵尸进程。
通过 ps au 命令可以看出,子进程仍然存在,并没有被销毁,僵尸进程在这里显示为 Z+ .30秒后,红 框里面的两个进程会同时被销毁。
利用 ./zombie & 可以使程序在后台运行,不用打开新的命令行窗口
-
销毁僵尸进程 1:利用 wait 函数
为了销毁子进程,父进程应该主动请求获取子进程的返回值。
1 2 3 4 5
#include <sys/wait.h> pid_t wait(int *statloc); /* 成功时返回终止的子进程 ID ,失败时返回 -1 */
调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit 函数的参数返回值,main函数的 return 返回值)将保存到该函数的参数所指的内存空间。但函数参数指向的单元中还包含其他信息,因此需要用下列宏进行分离:
- WIFEXITED 子进程正常终止时返回「真」
- WEXITSTATUS 返回子进程时的返回值
也就是说,向 wait 函数传递变量 status 的地址时,调用 wait 函数后应编写如下代码:
1 2 3 4 5
if (WIFEXITED(status)) { puts("Normal termination"); printf("Child pass num: %d", WEXITSTATUS(status)); }
调用 wait 函数时,如果没有已经终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此要谨慎调用该函数。
-
销毁僵尸进程 2:使用 waitpid 函数
wait 函数会引起程序阻塞,还可以考虑调用 waitpid 函数。这是防止僵尸进程的第二种方法,也是防止阻塞的方法。
1 2 3 4 5 6 7 8 9
#include <sys/wait.h> pid_t waitpid(pid_t pid, int *statloc, int options); /* 成功时返回终止的子进程ID 或 0 ,失败时返回 -1 pid: 等待终止的目标子进程的ID,若传 -1,则与 wait 函数相同,可以等待任意子进程终止 statloc: 与 wait 函数的 statloc 参数具有相同含义 options: 传递头文件 sys/wait.h 声明的常量 WNOHANG ,即使没有终止的子进程也不会进入阻塞 状态,而是返回 0 退出函数。 */
信号处理
我们已经知道了进程的创建及销毁的办法,但是还有一个问题没有解决。
子进程究竟何时终止?调用 waitpid 函数后要无休止的等待吗?
-
向操作系统求助
子进程终止的识别主题是操作系统,因此,若操作系统能把如下信息告诉正忙于工作的父进程,将有助于构建更高效的程序 为了实现上述的功能,引入信号处理机制(Signal Handing)。此处**「信号」是在特定事件发生时由操作系统向进程发送的消息**。另外,为了响应该消息,执行与消息相关的自定义操作的过程被称为「处理」或「信号处理」。
-
信号与signal函数
下面进程和操作系统的对话可以帮助理解信号处理。
进程:操作系统,如果我之前创建的子进程终止,就帮我调用 zombie_handler 函数。 操作系统:好的,如果你的子进程终止,我就帮你调用 zombie_handler 函数,你先把要函数要执行的语句写好。
上述的对话,相当于「注册信号」的过程。即进程发现自己的子进程结束时,请求操作系统调用的特定函数。该请求可以通过如下函数调用完成:
1 2 3 4 5 6 7 8
#include <signal.h> void (*signal(int signo, void (*func)(int)))(int); /* 为了在产生信号时调用,返回之前注册的函数指针 函数名: signal 参数:int signo,void(*func)(int) 返回类型:参数类型为int型,返回 void 型函数指针 */
调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出可以在 signal 函数中注册的部分特殊情况和对应的函数。
- SIGALRM:已到通过调用 alarm 函数注册时间
- SIGINT:输入 ctrl+c
- SIGCHLD:子进程终止
接下来编写调用 signal 函数的语句完成如下请求:
「子进程终止则调用 mychild 函数」
此时 mychild 函数的参数应为 int ,返回值类型应为 void 。只有这样才能成为 signal 函数的第二个参数。另外,常数 SIGCHLD 定义了子进程终止的情况,应成为 signal 函数的第一个参数。也就是说,signal 函数调用语句如下:
1
signal(1 SIGCHLD , #include);
接下来编写 signal 函数的调用语句,分别完成如下两个请求:
- 已到通过 alarm 函数注册时间,请调用 timeout 函数
- 输入 ctrl+c 时调用 keycontrol 函数
代表这 2 种情况的常数分别为 SIGALRM 和 SIGINT ,因此按如下方式调用 signal 函数。
1 2
signal(SIGALRM , timeout); signal(SIGINT , keycontrol);
以上就是信号注册过程。注册好信号之后,发生注册信号时(注册的情况发生时),操作系统将调用该信号对应的函数。先介绍 alarm 函数。
1 2 3
#include <unistd.h> unsigned int alarm(unsigned int seconds); // 返回0或以秒为单位的距 SIGALRM 信号发生所剩时间
如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递为 0 ,则之前对 SIGALRM 信号的预约将取消。如果通过改函数预约信号后未指定该信号对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
#include <stdio.h> #include <unistd.h> #include <signal.h> void timeout(int sig) //信号处理器 { if (sig == SIGALRM) puts("Time out!"); alarm(2); //为了每隔 2 秒重复产生 SIGALRM 信号,在信号处理器中调用 alarm 函数 } void keycontrol(int sig) //信号处理器 { if (sig == SIGINT) puts("CTRL+C pressed"); } int main(int argc, char *argv[]) { int i; signal(SIGALRM, timeout); //注册信号及相应处理器 signal(SIGINT, keycontrol); alarm(2); //预约 2 秒候发生 SIGALRM 信号 for (i = 0; i < 3; i++) { puts("wait..."); sleep(100); } return 0; }
编译运行:
1 2
gcc signal.c -o signal ./signal
结果:
上述结果是没有任何输入的运行结果。当输入 ctrl+c 时:
就可以看到 CTRL+C pressed 的字符串。
发生信号时将唤醒由于调用 sleep 函数而进入阻塞状态的进程。
调用函数的主题的确是操作系统,但是进程处于睡眠状态时无法调用函数,因此,产生信号时,为了调用信号处理器,将唤醒由于调用 sleep 函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到 sleep 中规定的时间也是如此。所以上述示例运行不到 10 秒后就会结束,连续输入 CTRL+C 可能连一秒都不到。
简言之,就是本来系统要睡眠100秒,但是到了 alarm(2) 规定的两秒之后,就会唤醒睡眠的进程,进程被唤醒了就不会再进入睡眠状态了,所以就不用等待100秒。如果把 timeout() 函数中的 alarm(2)注释掉,就会先输出wait… ,然后再输出Time out! (这时已经跳过了第一次的 sleep(100) 秒),然后就真的会睡眠100秒,因为没有再发出 alarm(2) 的信号。
-
利用sigaction函数进行信号处理
前面所学的内容可以防止僵尸进程,还有一个函数,叫做 sigaction 函数,他类似于 signal 函数,而且可以完全代替后者,也更稳定。之所以稳定,是因为:
signal 函数在 Unix 系列的不同操作系统可能存在区别,但 sigaction 函数完全相同
实际上现在很少用 signal 函数编写程序,它只是为了保持对旧程序的兼容,下面介绍 sigaction 函数,只讲解可以替换 signal 函数的功能。
1 2 3 4 5 6 7 8
#include <signal.h> int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact); /* 成功时返回 0 ,失败时返回 -1 act: 对于第一个参数的信号处理函数(信号处理器)信息。 oldact: 通过此参数获取之前注册的信号处理函数指针,若不需要则传递 0 */
声明并初始化 sigaction 结构体变量以调用上述函数,该结构体定义如下:
1 2 3 4 5 6
struct sigaction { void (*sa_handler)(int); sigset_t sa_mask; int sa_flags; };
此结构体的成员 sa_handler 保存信号处理的函数指针值(地址值)。sa_mask 和 sa_flags 的所有位初始化 0 即可。这 2 个成员用于指定信号相关的选项和特性,而我们的目的主要是防止产生僵尸进程,故省略。
下面的示例是关于 sigaction 函数的使用方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
#include <stdio.h> #include <unistd.h> #include <signal.h> void timeout(int sig) { if (sig == SIGALRM) puts("Time out!"); alarm(2); } int main(int argc, char *argv[]) { int i; struct sigaction act; act.sa_handler = timeout; //保存函数指针 sigemptyset(&act.sa_mask); //将 sa_mask 函数的所有位初始化成0 act.sa_flags = 0; //sa_flags 同样初始化成 0 sigaction(SIGALRM, &act, 0); //注册 SIGALRM 信号的处理器。 alarm(2); //2 秒后发生 SIGALRM 信号 for (int i = 0; i < 3; i++) { puts("wait..."); sleep(100); } return 0; }
编译运行:
1 2
gcc sigaction.c -o sigaction ./sigaction
结果:
1 2 3 4 5 6
wait... Time out! wait... Time out! wait... Time out!
可以发现,结果和之前用 signal 函数的结果没有什么区别。以上就是信号处理的相关理论。
-
利用信号处理技术消灭僵尸进程
下面利用子进程终止时产生 SIGCHLD 信号这一点,来用信号处理来消灭僵尸进程。看以下代码:
remove_zomebie.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h> void read_childproc(int sig) { int status; pid_t id = waitpid(-1, &status, WNOHANG); if (WIFEXITED(status)) { printf("Removed proc id: %d \n", id); //子进程的 pid printf("Child send: %d \n", WEXITSTATUS(status)); //子进程的返回值 } } int main(int argc, char *argv[]) { pid_t pid; struct sigaction act; act.sa_handler = read_childproc; sigemptyset(&act.sa_mask); act.sa_flags = 0; sigaction(SIGCHLD, &act, 0); pid = fork(); if (pid == 0) //子进程执行阶段 { puts("Hi I'm child process 12"); sleep(10); return 12; } else //父进程执行阶段 { printf("Child proc id: %d\n", pid); pid = fork(); if (pid == 0) { puts("Hi! I'm child process 24"); sleep(10); exit(24); } else { int i; printf("Child proc id: %d \n", pid); for (i = 0; i < 5; i++) { puts("wait"); sleep(5); } } } return 0; }
编译运行,其结果为:
1 2 3 4 5 6 7 8 9 10 11 12 13
Child proc id: 11211 Hi I'm child process Child proc id: 11212 wait Hi! I'm child process wait wait Removed proc id: 11211 Child send: 12 wait Removed proc id: 11212 Child send: 24 wait
请自习观察结果,结果中的每一个空行代表间隔了5 秒,程序是先创建了两个子进程,然后子进程 10秒之后会返回值,第一个 wait 由于子进程在执行,所以直接被唤醒,然后这两个子进程正在睡 10 秒,所以 5 秒之后第二个 wait 开始执行,又过了 5 秒,两个子进程同时被唤醒。所以剩下的 wait 也被唤醒。 所以在本程序的过程中,当子进程终止时候,会向系统发送一个信号,然后调用我们提前写好的处理函数,在处理函数中使用 waitpid 来处理僵尸进程,获取子进程返回值。
基于多任务的并发处理器
-
基于进程的并发服务器模型
之前的回声服务器每次只能同时向 1 个客户端提供服务。因此,需要扩展回声服务器,使其可以同时向多个客户端提供服务。下图是基于多进程的回声服务器的模型。
从图中可以看出,每当有客户端请求时(连接请求),回声服务器都创建子进程以提供服务。如果请求的客户端有 5 个,则将创建 5 个子进程来提供服务,为了完成这些任务,需要经过如下过程:
- 第一阶段:回声服务器端(父进程)通过调用 accept 函数受理连接请求
- 第二阶段:此时获取的套接字文件描述符创建并传递给子进程
- 第三阶段:进程利用传递来的文件描述符提供服务
-
通过 fork 函数复制文件描述符
示例中给出了通过 fork 函数复制文件描述符的过程。父进程将 2 个套接字(一个是服务端套接字另一个是客户端套接字)文件描述符复制给了子进程。
调用 fork 函数时复制父进程的所有资源,但是套接字不是归进程所有的,而是归操作系统所有,只是进程拥有代表相应套接字的文件描述符。
如图所示,1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。如果维持图中的状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法销毁套接字(服务器套接字同样如此)。因此调用 fork 函数时,要将无关紧要的套接字文件描述符关掉,如图所示:
分割TCP的I / O 程序
-
分割 I/O 的优点
我们已经实现的回声客户端的数据回声方式如下:
向服务器传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。
传输数据后要等待服务器端返回的数据,因为程序代码中重复调用了 read 和 write 函数。只能这么写的原因之一是,程序在 1 个进程中运行,现在可以创建多个进程,因此可以分割数据收发过程。默认分割过程如下图所示:
从图中可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。
分割 I/O 程序的另外一个好处是,可以提高频繁交换数据的程序性能,图下图所示:
根据上图显示可以看出,再网络不好的情况下,明显提升速度。
进程间通信
进程间通信,意味着可以在两个不同的进程中可以交换数据。
进程间通信的基本概念
-
通过管道(PIPE)的进程间通信
模型:
可以看出,为了完成进程间通信,需要创建进程。管道并非属于进程的资源,而是和套接字一样,属于操作系统(也就不是 fork 函数的复制对象)。所以,两个进程通过操作系统提供的内存空间进行通信。下面是创建管道的函数。
1 2 3 4 5 6 7
#include <unistd.h> int pipe(int filedes[2]); /* 成功时返回 0 ,失败时返回 -1 filedes[0]: 通过管道接收数据时使用的文件描述符,即管道出口 filedes[1]: 通过管道传输数据时使用的文件描述符,即管道入口 */
父进程创建函数时将创建管道,同时获取对应于出入口的文件描述符,此时父进程可以读写同一管道。但父进程的目的是与子进程进行数据交换,因此需要将入口或出口中的 1 个文件描述符传递给子进程。下面的例子是关于该函数的使用方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
#include <stdio.h> #include <unistd.h> #define BUF_SIZE 30 int main(int argc, char *argv[]) { int fds[2]; char str[] = "Who are you?"; char buf[BUF_SIZE]; pid_t pid; // 调用 pipe 函数创建管道,fds 数组中保存用于 I/O 的文件描述符 pipe(fds); pid = fork(); //子进程将同时拥有创建管道获取的2个文件描述符,复制的并非管道,而是文件描述符 if (pid == 0) { write(fds[1], str, sizeof(str)); } else { read(fds[0], buf, BUF_SIZE); puts(buf); } return 0; }
编译运行,其结果为:
1
Who are you?
可以从程序中看出,首先创建了一个管道,子进程通过 fds[1] 把数据写入管道,父进程从 fds[0] 再把 数据读出来。可以从下图看出:
-
通过管道进行进程间双向通信
模型:
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
#include <stdio.h> #include <unistd.h> #define BUF_SIZE 30 int main(int argc, char *argv[]) { int fds[2]; char str1[] = "Who are you?"; char str2[] = "Thank you for your message"; char buf[BUF_SIZE]; pid_t pid; pipe(fds); pid = fork(); if (pid == 0) { write(fds[1], str1, sizeof(str1)); sleep(2); read(fds[0], buf, BUF_SIZE); printf("Child proc output: %s \n", buf); } else { read(fds[0], buf, BUF_SIZE); printf("Parent proc output: %s \n", buf); write(fds[1], str2, sizeof(str2)); sleep(3); } return 0; }
结果:
1 2
Parent proc output: Who are you? Child proc output: Thank you for your message
运行结果是正确的,但是如果注释掉第18行的代码,就会出现问题,导致一直等待下去。因为数据进入管道后变成了无主数据。也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。因为,注释第18行会产生问题。第19行,自己成将读回自己在第 17 行向管道发送的数据。结果父进程调用 read 函数后,无限期等待数据进入管道。
当一个管道不满足需求时,就需要创建两个管道,各自负责不同的数据流动,过程如下图所示:
下面采用上述模型改进【双向通信模型1】.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
#include <stdio.h> #include <unistd.h> #define BUF_SIZE 30 int main(int argc, char *argv[]) { int fds1[2], fds2[2]; char str1[] = "Who are you?"; char str2[] = "Thank you for your message"; char buf[BUF_SIZE]; pid_t pid; pipe(fds1), pipe(fds2); pid = fork(); if (pid == 0) { write(fds1[1], str1, sizeof(str1)); read(fds2[0], buf, BUF_SIZE); printf("Child proc output: %s \n", buf); } else { read(fds1[0], buf, BUF_SIZE); printf("Parent proc output: %s \n", buf); write(fds2[1], str2, sizeof(str2)); } return 0; }
上面通过创建两个管道实现了功能,此时,不需要额外再使用 sleep 函数。运行结果和上面一样。
运用进程间通信
-
保存消息的回声服务器
下面对第 10 章的 echo_mpserv.c 进行改进,添加一个功能:
将回声客户端传输的字符串按序保存到文件中
实现该任务将创建一个新进程,从向客户端提供服务的进程读取字符串信息,下面是代码: echo_storeserv.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h> #include <arpa/inet.h> #include <sys/socket.h> #define BUF_SIZE 30 void error_handling(char *message); void read_childproc(int sig); int main(int argc, char *argv[]) { int serv_sock, clnt_sock; struct sockaddr_in serv_adr, clnt_adr; int fds[2]; pid_t pid; struct sigaction act; socklen_t adr_sz; int str_len, state; char buf[BUF_SIZE]; if (argc != 2) { printf("Usgae : %s <port>\n", argv[0]); exit(1); } act.sa_handler = read_childproc; //防止僵尸进程 sigemptyset(&act.sa_mask); act.sa_flags = 0; state = sigaction(SIGCHLD, &act, 0); //注册信号处理器,把成功的返回 值给 state serv_sock = socket(PF_INET, SOCK_STREAM, 0); //创建服务端套接字 memset(&serv_adr, 0, sizeof(serv_adr)); serv_adr.sin_family = AF_INET; serv_adr.sin_addr.s_addr = htonl(INADDR_ANY); serv_adr.sin_port = htons(atoi(argv[1])); if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1) //分配IP地址和端口号 error_handling("bind() error"); if (listen(serv_sock, 5) == -1) //进入等待连接请求状态 error_handling("listen() error"); pipe(fds); pid = fork(); if (pid == 0) { FILE *fp = fopen("echomsg.txt", "wt"); char msgbuf[BUF_SIZE]; int i, len; for (int i = 0; i < 10; i++) { len = read(fds[0], msgbuf, BUF_SIZE); fwrite((void *)msgbuf, 1, len, fp); } fclose(fp); return 0; } while (1) { adr_sz = sizeof(clnt_adr); clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz); if (clnt_sock == -1) continue; else puts("new client connected..."); pid = fork(); //此时,父子进程分别带有一个套接字 if (pid == 0) //子进程运行区域,此部分向客户端提供回声服务 { close(serv_sock); //关闭服务器套接字,因为从父进程传递到了子进程 while ((str_len = read(clnt_sock, buf, BUFSIZ)) != 0) { write(clnt_sock, buf, str_len); write(fds[1], buf, str_len); } close(clnt_sock); puts("client disconnected..."); return 0; } else close(clnt_sock); //通过 accept 函数创建的套接字文件描述符已经复制给子进程,因为服务器端要销毁自己拥有的 } close(serv_sock); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); } void read_childproc(int sig) { pid_t pid; int status; pid = waitpid(-1, &status, WNOHANG); printf("removed proc id: %d \n", pid); }
编译运行:
1 2
gcc echo_storeserv.c -o serv ./serv 9190
此服务端配合第 10 章的客户端 echo_mpclient.c 使用,运行结果如下图:
从图上可以看出,服务端已经生成了文件,把客户端的消息保存可下来,只保存了10次消息。
I/O复用
基于I/O复用的服务器端
-
多进程服务端的缺点和解决方法
为了构建并发服务器,只要有客户端连接请求就会创建新进程。这的确是实际操作中采用的一种方案,但并非十全十美,因为创建进程要付出很大的代价。这需要大量的运算和内存空间,由于每个进程都具有独立的内存空间,所以相互间的数据交换也要采用相对复杂的方法(IPC 属于相对复杂的通信方法)。
I/O 复用技术可以解决这个问题。
-
理解复用
「复用」在电子及通信工程领域很常见,向这些领域的专家询问其概念,可能会得到如下答复:
在 1 个通信频道中传递多个数据(信号)的技术
「复用」的含义:
为了提高物理设备的效率,只用最少的物理要素传递最多数据时使用的技术。
上述两种方法的内容完全一致。可以用纸电话模型做一个类比:
上图是一个纸杯电话系统,为了使得三人同时通话,说话时要同时对着两个纸杯,接听时也需要耳朵同时对准两个纸杯。为了完成 3 人通话,可以进行如下图的改进:
如图做出改进,就是引入了复用技术。
复用技术的优点:
- 减少连线长度
- 减少纸杯个数
即使减少了连线和纸杯的量仍然可以进行三人同时说话,但是如果碰到以下情况:
「好像不能同时说话?」
实际上,因为是在进行对话,所以很少发生同时说话的情况。也就是说,上述系统采用的是「时分复用」技术。因为说话人声频率不同,即使在同时说话也能进行一定程度上的区分(杂音也随之增多)。因此,也可以说是「频分复用技术」。
-
复用技术在服务器端的应用
纸杯电话系统引入复用技术之后可以减少纸杯数量和连线长度。服务器端引入复用技术可以减少所需进程数。下图是多进程服务端的模型:
下图是引入复用技术之后的模型:
从图上可以看出,引入复用技术之后,可以减少进程数。重要的是,无论连接多少客户端,提供服务的进程只有一个。
理解select函数并实现服务端
select 函数是最具代表性的实现复用服务器的方法。在 Windows 平台下也有同名函数,所以具有很好的移植性。
-
select 函数的功能和调用顺序
使用 select 函数时可以将多个文件描述符集中到一起统一监视,项目如下:
- 是否存在套接字接收数据?
- 无需阻塞传输数据的套接字有哪些?
- 哪些套接字发生了异常?
术语:「事件」。当发生监视项对应情况时,称「发生了事件」。
select 函数的使用方法与一般函数的区别并不大,更准确的说,他很难使用。但是为了实现 I/O 复用服务器端,我们应该掌握 select 函数,并运用于套接字编程当中。认为「select 函数是 I/O 复用的全部内容」也并不为过。
select 函数的调用过程如下图所示:
-
设置文件描述符
利用 select 函数可以同时监视多个文件描述符。当然,监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中在一起。集中时也要按照监视项(接收、传输、异常)进行区分,即按照上述 3 种监视项分成 3 类。
利用 fd_set 数组变量执行此操作,如图所示,该数组是存有0和1的位数组。
图中最左端的位表示文件描述符 0(所在位置)。如果该位设置为 1,则表示该文件描述符是监视对象。那么图中哪些文件描述符是监视对象呢?很明显,是描述符 1 和 3。在 fd_set 变量中注册或更改值的操作都由下列宏完成。
FD_ZERO(fd_set *fdset)
:将 fd_set 变量所指的位全部初始化成0FD_SET(int fd,fd_set *fdset)
:在参数 fdset 指向的变量中注册文件描述符 fd 的信息FD_SLR(int fd,fd_set *fdset)
:从参数 fdset 指向的变量中清除文件描述符 fd 的信息FD_ISSET(int fd,fd_set *fdset)
:若参数 fdset 指向的变量中包含文件描述符 fd 的信息,则返回「真」
上述函数中,FD_ISSET 用于验证 select 函数的调用结果,通过下图解释这些函数的功能:
-
设置检查(监视)范围及超时
下面是 select 函数的定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
#include <sys/select.h> #include <sys/time.h> int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout); /* 成功时返回大于 0 的值,失败时返回 -1 maxfd: 监视对象文件描述符数量 readset: 将所有关注「是否存在待读取数据」的文件描述符注册到 fd_set 型变量,并传递其地址 值。 writeset: 将所有关注「是否可传输无阻塞数据」的文件描述符注册到 fd_set 型变量,并传递其 地址值。 exceptset: 将所有关注「是否发生异常」的文件描述符注册到 fd_set 型变量,并传递其地址值。 timeout: 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息 返回值: 发生错误时返回 -1,超时时返回0,。因发生关注的时间返回时,返回大于0的值,该值是发生 事件的文件描述符数。 */
如上所述,select 函数用来验证 3 种监视的变化情况,根据监视项声明 3 个 fd_set 型变量,分别向其注册文件描述符信息,并把变量的地址值传递到上述函数的第二到第四个参数。但在此之前(调用select 函数之前)需要决定下面两件事:
- 文件描述符的监视(检查)范围是?
- 如何设定 select 函数的超时时间?
第一,文件描述符的监视范围和 select 的第一个参数有关。实际上,select 函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在 fd_set 变量中的文件描述符数。但每次新建文件描述符时,其值就会增加 1 ,故只需将最大的文件描述符值加 1 再传递给 select 函数即可。加 1 是因为文件描述符的值是从 0 开始的。 第二,select 函数的超时时间与 select 函数的最后一个参数有关,其中 timeval 结构体定义如下:
1 2 3 4 5
struct timeval { long tv_sec; long tv_usec; };
本来 select 函数只有在监视文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过上述结构体变量,将秒数填入 tv_sec 的成员,将微妙数填入 tv_usec 的成员,然后将结构体的地址值传递到 select 函数的最后一个参数。此时,即使文件描述符未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下, select 函数返回 0 。因此,可以通过返回值了解原因。如果不向设置超时,则传递 NULL 参数。
-
调用 select 函数查看结果
select 返回正整数时,怎样获知哪些文件描述符发生了变化?向 select 函数的第二到第四个参数传递的fd_set 变量中将产生如图所示的变化:
由图可知,select 函数调用完成候,向其传递的 fd_set 变量将发生变化。原来为 1 的所有位将变成 0,但是发生了变化的文件描述符除外。因此,可以认为值仍为 1 的位置上的文件描述符发生了变化。
-
select 函数调用示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
#include <stdio.h> #include <unistd.h> #include <sys/time.h> #include <sys/select.h> #define BUF_SIZE 30 int main(int argc, char *argv[]) { fd_set reads, temps; int result, str_len; char buf[BUF_SIZE]; struct timeval timeout; FD_ZERO(&reads); //初始化变量 FD_SET(0, &reads); //将文件描述符0对应的位设置为1 /* timeout.tv_sec=5; timeout.tv_usec=5000; */ while (1) { temps = reads; //为了防止调用了select 函数后,位的内容改变,先提前存一下 timeout.tv_sec = 5; timeout.tv_usec = 0; result = select(1, &temps, 0, 0, &timeout); //如果控制台输入数据,则返回大于0的数,没有就会超时 if (result == -1) { puts("select error!"); break; } else if (result == 0) { puts("Time-out!"); } else { if (FD_ISSET(0, &temps)) //验证发生变化的值是否是标准输入端,0为输入端, 1 为输出端 2为错误端 { str_len = read(0, buf, BUF_SIZE); buf[str_len] = 0; printf("message from console: %s", buf); } } } return 0; }
编译运行,其结果为:
可以看出,如果运行后在标准输入流输入数据,就会在标准输出流输出数据,但是如果 5 秒没有输入数 据,就提示超时。
-
实现 I/O 复用服务器端
下面通过 select 函数实现 I/O 复用服务器端。下面是基于 I/O 复用的回声服务器端。 echo_selectserv.c 编译运行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/time.h> #include <sys/select.h> #define BUF_SIZE 100 void error_handling(char *message); int main(int argc, char *argv[]) { int serv_sock, clnt_sock; struct sockaddr_in serv_adr, clnt_adr; struct timeval timeout; fd_set reads, cpy_reads; socklen_t adr_sz; int fd_max, str_len, fd_num, i; char buf[BUF_SIZE]; if (argc != 2) { printf("Usage : %s <port>\n", argv[0]); exit(1); } serv_sock = socket(PF_INET, SOCK_STREAM, 0); memset(&serv_adr, 0, sizeof(serv_adr)); serv_adr.sin_family = AF_INET; serv_adr.sin_addr.s_addr = htonl(INADDR_ANY); serv_adr.sin_port = htons(atoi(argv[1])); if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1) error_handling("bind() error"); if (listen(serv_sock, 5) == -1) error_handling("listen() error"); FD_ZERO(&reads); FD_SET(serv_sock, &reads); //注册服务端套接字 fd_max = serv_sock; while (1) { cpy_reads = reads; timeout.tv_sec = 5; timeout.tv_usec = 5000; if ((fd_num = select(fd_max + 1, &cpy_reads, 0, 0, &timeout)) == -1) //开始监视,每次重新监听 break; if (fd_num == 0) continue; for (i = 0; i < fd_max + 1; i++) { if (FD_ISSET(i, &cpy_reads)) //查找发生变化的套接字文件描述符 { if (i == serv_sock) //如果是服务端套接字时,受理连接请求 { adr_sz = sizeof(clnt_adr); clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz); FD_SET(clnt_sock, &reads); //注册一个clnt_sock if (fd_max < clnt_sock) fd_max = clnt_sock; printf("Connected client: %d \n", clnt_sock); } else //不是服务端套接字时 { str_len = read(i, buf, BUF_SIZE); //i指的是当前发起请求的客户端 if (str_len == 0) { FD_CLR(i, &reads); close(i); printf("closed client: %d \n", i); } else { write(i, buf, str_len); } } } } } close(serv_sock); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
编译第四章回声客户端。其结果如下:
从图上可以看出,虽然只用了一个进程,但是却实现了可以和多个客户端进行通信,这都是利用了select 的特点。
多种I/O函数
send & recv函数
readv & writev函数
套接字和标准I/O
优于select的epoll
epoll理解及应用
select 复用方法由来已久,因此,利用该技术后,无论如何优化程序性能也无法同时接入上百个客户端。这种 select 方式并不适合以 web 服务器端开发为主流的现代开发环境,所以需要学习 Linux 环境下的 epoll。
-
基于 select 的 I/O 复用技术速度慢的原因
第 12 章实现了基于 select 的 I/O 复用技术服务端,其中有不合理的设计如下:
- 调用 select 函数后常见的针对所有文件描述符的循环语句;
- 每次调用 select 函数时都需要向该函数传递监视对象信息;
上述两点可以从 echo_selectserv.c 得到确认,调用 select 函数后,并不是把发生变化的文件描述符单独集中在一起,而是通过作为监视对象的 fd_set 变量的变化,找出发生变化的文件描述符(54,56行),因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的 fd_set 会发生变化,所以调用 select 函数前应该复制并保存原有信息,并在每次调用 select 函数时传递新的监视对象信息。
select 性能上最大的弱点是:每次传递监视对象信息,准确的说,select 是监视套接字变化的函数。而套接字是操作系统管理的,所以 select 函数要借助操作系统才能完成功能。select 函数的这一缺点可以通过如下方式弥补:
仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项。
这样就无需每次调用 select 函数时都想操作系统传递监视对象信息,但是前提操作系统支持这种处理方式。Linux 的支持方式是 epoll ,Windows 的支持方式是 IOCP。
-
select 也有优点
select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,使用 select 函数满足以下两个条件:
- 服务器接入者少
- 程序应该具有兼容性
-
实现 epoll 时必要的函数和结构体
能够克服 select 函数缺点的 epoll 函数具有以下优点,这些优点正好与之前的 select 函数缺点相反。
- 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句;
- 调用对应于 select 函数的 epoll_wait 函数时无需每次传递监视对象信息。
下面是 epoll 函数的功能:
- epoll_create:创建保存 epoll 文件描述符的空间
- epoll_ctl:向空间注册并注销文件描述符
- epoll_wait:与 select 函数类似,等待文件描述符发生变化
select 函数中为了保存监视对象的文件描述符,直接声明了 fd_set 变量,但 epoll 方式下的操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时用的函数就是epoll_create 。
- 此外,为了添加和删除监视对象文件描述符,select 方式中需要 FD_SET、FD_CLR 函数。但在 epoll 方式中,通过 epoll_ctl 函数请求操作系统完成。
- 最后,select 方式下调用 select 函数等待文件描述符的变化,而 epoll方式下 调用 epoll_wait 函数。
- 还有,select 方式中通过 fd_set 变量查看监视对象的状态变化,而 epoll 方式通过如下结构体 epoll_event 将发生变化的文件描述符单独集中在一起。
1 2 3 4 5 6 7 8 9 10 11
struct epoll_event { __uint32_t events; epoll_data_t data; }; typedef union epoll_data { void *ptr; int fd; __uint32_t u32; __uint64_t u64; } epoll_data_t;
声明足够大的 epoll_event 结构体数组候,传递给 epoll_wait 函数时,发生变化的文件描述符信息将被填入数组。因此,无需像 select 函数那样针对所有文件描述符进行循环。
-
epoll_create
epoll 是从 Linux 的 2.5.44 版内核开始引入的。通过以下命令可以查看 Linux 内核版本:
1
cat /proc/1 sys/kernel/osrelease
下面是 epoll_create 函数的原型:
1 2 3 4 5 6
#include <sys/epoll.h> int epoll_create(int size); /* 成功时返回 epoll 的文件描述符,失败时返回 -1 size:epoll 实例的大小 */
调用 epoll_create 函数时创建的文件描述符保存空间称为「epoll 例程」,但有些情况下名称不同,需要稍加注意。通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提出的建议。换言之,size 并不用来决定 epoll 的大小,而仅供操作系统参考。
Linux 2.6.8 之后的内核将完全传入 epoll_create 函数的 size 函数,因此内核会根据情况调整epoll 例程大小。但是本书程序并没有忽略 size
epoll_create 函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符,也就是说返回的文件描述符主要用于区分 epoll 例程。需要终止时,与其他文件描述符相同,也要调用 close 函数.
-
epoll_ctl
生成例程后,应在其内部注册监视对象文件描述符,此时使用 epoll_ctl 函数。
1 2 3 4 5 6 7 8 9
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); /* 成功时返回 0 ,失败时返回 -1 epfd:用于注册监视对象的 epoll 例程的文件描述符 op:用于制定监视对象的添加、删除或更改等操作 fd:需要注册的监视对象文件描述符 event:监视对象的事件类型 */
与其他 epoll 函数相比,该函数看起来有些复杂,但通过调用语句就很容易理解,假设按照如下形式调用 epoll_ctl 函数:
1
epoll_ctl(A,1 EPOLL_CTL_ADD,B,C);
第二个参数 EPOLL_CTL_ADD 意味着「添加」,上述语句有如下意义:
epoll 例程 A 中注册文件描述符 B ,主要目的是为了监视参数 C 中的事件
再介绍一个调用语句。
1
epoll_ctl(A,EPOLL_CTL_DEL,B,NULL);
上述语句中第二个参数意味这「删除」,有以下含义:
从 epoll 例程 A 中删除文件描述符 B
从上述示例中可以看出,从监视对象中删除时,不需要监视类型,因此向第四个参数可以传递为 NULL 下面是第二个参数的含义:
- EPOLL_CTL_ADD:将文件描述符注册到 epoll 例程
- EPOLL_CTL_DEL:从 epoll 例程中删除文件描述符
- EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况
epoll_event 结构体用于保存事件的文件描述符结合。但也可以在 epoll 例程中注册文件描述符时,用于注册关注的事件。该函数中 epoll_event 结构体的定义并不显眼,因此通过掉英语剧说明该结构体在epoll_ctl 函数中的应用。
1 2 3 4 5 6
struct epoll_event event; ... event.events=EPOLLIN;//发生需要读取数据的情况时 event.data.fd=sockfd; epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&event); ...
上述代码将 epfd 注册到 epoll 例程 epfd 中,并在需要读取数据的情况下产生相应事件。接下来给出epoll_event 的成员 events 中可以保存的常量及所指的事件类型。
- EPOLLIN:需要读取数据的情况
- EPOLLOUT:输出缓冲为空,可以立即发送数据的情况
- EPOLLPRI:收到 OOB 数据的情况
- EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发方式下非常有用
- EPOLLERR:发生错误的情况
- EPOLLET:以边缘触发的方式得到事件通知
- EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL_CTL_MOD ,再次设置事件。
可通过位运算同时传递多个上述参数。
-
epoll_wait
下面是函数原型:
1 2 3 4 5 6 7 8 9 10
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); /* 成功时返回发生事件的文件描述符,失败时返回 -1 epfd : 表示事件发生监视范围的 epoll 例程的文件描述符 events : 保存发生事件的文件描述符集合的结构体地址值 maxevents : 第二个参数中可以保存的最大事件数 timeout : 以 1/1000 秒为单位的等待时间,传递 -1 时,一直等待直到发生事件 */
该函数调用方式如下。需要注意的是,第二个参数所指缓冲需要动态分配。
1 2 3 4 5 6 7
int event_cnt; struct epoll_event *ep_events; ... ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量 ... event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1); ...
调用函数后,返回发生事件的文件描述符,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像 select 一样插入针对所有文件描述符的循环。
总结epoll的流程:
- epoll_create 创建一个保存 epoll 文件描述符的空间,可以没有参数
- 动态分配内存,给将要监视的 epoll_wait
- 利用 epoll_ctl 控制 添加 删除,监听事件
- 利用 epoll_wait 来获取改变的文件描述符,来执行程序
select 和 epoll 的区别:
- 每次调用 select 函数都会向操作系统传递监视对象信息,浪费大量时间
- epoll 仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项
条件触发和边缘触发
多线程服务器端的实现
理解线程的概念
线程创建及运行
线程存在的问题和临界区
线程同步
线程的销毁和多线程并发服务器的实现
文章作者 fzhiy
上次更新 2022-03-01