十七章 并发 Socket 程序设计
非阻塞并发模型
I/O 阻塞是影响进程并发的重要原因 , 进程一旦进入阻塞 , 就不能再执行任何操作 . 比如进程调用输入函数后 , 在默认情况下必须一直阻塞到产生满足条件的数据为止 .
套接字也使一种 I/O 设备 , 它有四类阻塞性交易 , 分别是输入类交易 (read, recv 和 recvfrom 等 ), 输出类交易 (write 和 send 等 ), 连接申请交易 (connect) 和连接处理交易 (accept), 其中 UDP 协议数据发送函数 sendto 不产生阻塞 .
当套接字调用以上函数时 , 将导致进程阻塞 . 因此把套接字函数的阻塞模式更改为非阻塞模式 , 是实现并发套接字程序的一种方法 .
(1) 非阻塞套接字系统调用
函数 fcntl 设置套接字描述符的 O_NONBLOCK 标志后 , 即可将 I/O 方式更改为非阻塞方式 .
下面代码将套接字描述符 nSock 设置为非阻塞方式 .
int val; val = fcntl(nSock, F_GETFL, 0); fcntl(nSock, F_SETFL, val | O_NONBLOCK); |
此时 , 当调用函数的成功条件不满足时 , 将立即返回 -1 并置 errno 为 EAGAIN 或 EINPROGRESS 错误 .
(2) 非阻塞套接字程序设计流程
非阻塞套接字的程序一般包含一段循环代码 , 在相互中采取轮询的方式 , 分别调用套接字的输入 , 输出 , 申请连接或连接处理函数 , 从而得到并发处理多个套接字的目的 .
这里设计一个运行在非阻塞方式下的服务器端套接字程序 , 说明通过非阻塞方式实现并发处理的流程 . 它首先在端口 PORT 上创建一个 TCP 侦听套接字 , 然后一边处理客户端的套接字连接申请 , 一边从已连接的客户端接收数据信息 .
/*------------------- 定义设置非阻塞模式宏 --------------------*/ #define Fsetnonblock(a) / { val = fcntl(a, F_GETFL, 0); fcntl(nSock, F_SETFL, val | O_NONBLOCK); }
int nLisSock; int i, n = 0, nSockVar[MAX]; int val; char buf[1024];
/*------------------- 主流程 --------------------*/ CreateSock(&nLisSock, PORT, MAX); Fsetnonblock(nLisSock); /*--- 循环代码 , 论询 nLisSock 套接字的 accept 函数和连接套接字的 read 函数 ---*/ while(1){ /*--- 创建套接字描述符 nSockVar[n] 与客户端套接字建立连接 ---*/ if((nSockVar[n] = accept(nLisSock, NULL, NULL)) > 0){ /*--- 设置新创建的套接字描述符的非阻塞属性 ---*/ Fsetnonblock(nSockVar[n++]); } /*--- 遍历每一个套接字 , 并从客户端套接字中读入数据 ---*/ for(i=0;i<n;i++){ read(nSockVar[i], buf, sizeof(buf)); /*--- 其他的处理代码 ---*/ ... ... ... ... } } |
从上面可以看到 , 进程虽然能够同时处理一个套接字的侦听和 n 个套接字的数据接收操作 , 却存在两个缺陷 :
a. 此代码采用了非阻塞轮询的方式 , 极大地浪费了 CPU 时间 .
b. 此代码没有解决循环的跳出问题 , 进程很有可能一直循环下去 .
信号驱动并发模型
有过 Windows 下开发经验的读者都知道消息驱动的概念 . 比如使用 Win32 程序 , 平时挂起或执行其他的操作 , 当套接字有输入发生时 , Windows 产生消息并将此消息发送到目标进程 . 进程接到此消息 , 自动调用响应的处理代码 , 完成套接字数据的读取过程 .
当文件描述符就绪时 , UNIX 内核会向进程发送 SIGIO 信号 , 进程获取该信号并调用预先准备的处理函数执行 I/O 操作 . 当函数调用完毕后 , 进程回到接收信号前的代码处继续工作 . 在一个套接字上实现信号驱动的步骤如下 :
(1) 为信号 SIGIO 设计处理函数被捕获信号
Void func(int sig){ // 信号处理函数 ... ... ... ... signal(SIGIO, func); } ... ... ... ... signal(SIGIO, func); // 捕获信号 SIGIO |
(2) 设定套接字的归属主 , 设置接收 SIGIO 信号的进程或进程组 .
Fcntl(nSock, F_SETOWN, getid()); // 设置接收信号的进程 或者 fcntl(nSock, F_SETOWN, 0 – getpgrp()); // 设置接收信号的进程组 |
(3) 设置套接字的异步 I/O 属性 .
int val; val = fcntl(nSock, F_GETFL, 0); fcntl(nSock, F_SETFL, val | O_ASYNC); |
然而 , 套接字信号驱动的设置尽管比较简单 , 但在实践中却难以实现 . 因为 UNIX 内核并不仅仅在套接字有输入时才发送 SIGIO 信号 , 在套接字连接 , 输出 , 错误和其他状态变化时均发送该信号 , 这样就导致了进程在接收信号后无法正确地判断下一步的行为 .
超时并发模型
超时也是防止阻塞的一种手段 , 它可以保证进程不被永远挂起 . 阻塞函数的超时时间长度决定了进程的并发程度 , 超时时间越小 , 并发度越高 , 超时时间越大 , 则并发度越低 .
在 UNIX 下的阻塞函数一般都有在进程接收到任意信号后终端返回的传统 , 套接字函数也不例外 , 利用这个特性使用定时器信号 SIGALRM 实现套接字的超时处理 .
下面我们使用信号加跳转方式设置超时 :
(1) 定义超时标志和跳转结构 .
(2) 为信号 SIGALRM 设计处理函数
(3) 记录跳转点
(4) 超时判断
(5) 捕获信号 SIGALRM 并设置定时器
(6) 调用套接字阻塞处理函数 , 比如套接字接收 , 发送 , 连接申请或者接收连接等
(7) 取消定时器并忽略信号 SIGALRM.
本处设计一个套接字连接函数 connect 超时处理的例子 tcpto1.c, 它从命令行输入 IP 地址和端口 , 程序向该 IP 地址和端口建立连接 , 如果连接失败 , 10 秒钟后超时退出 , 代码如下
#include "tcp.h"
static int nTimeout = 0; // (1) 定义超时标志设置 jmp_buf env; // (1) 定义跳转结构
void OnTimeOut(int nSignal){ // (2) 信号处理函数 signal(nSignal, SIG_IGN); nTimeout = 1; longjmp(env, 1); return; }
int main(int argc, char *argv[]) { int nSock = -1, ret;
if(argc != 3) return 1; nTimeout = 0; setjmp(env); // (3) 记录跳转点 if(nTimeout == 1) // (4) 超时判断 printf("Connect Timeout./n"); else{ signal(SIGALRM, OnTimeOut); // (5) 捕获信号 SIGALRM alarm(3); // (5) 发送定时器信号 SIGALRM ret = ConnectSock(&nSock, atoi(argv[2]), argv[1]); // (6) 执行函数 alarm(0); // (7) 取消定时器 signal(SIGALRM, SIG_IGN); // (7) 忽略信号 SIGALRM if(ret == 0) printf("Connect Success./n"); else printf("Connect Error./n"); } if(nSock != -1) close(nSock);
return 0; } |
其中 , 用到了一个头文件 tcp.h, 其中整合了 ConnectSock 函数 , 代码如下 :
[root@billstone Unix_study]# cat tcp.h #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <signal.h> #include <setjmp.h> #include <stdio.h> #include <errno.h> #include <fcntl.h> #include <assert.h>
int ConnectSock(int *pSock, int nPort, char *pAddr){ struct sockaddr_in addrin; long lAddr; int nSock;
assert(pSock != NULL && nPort > 0 && pAddr != NULL); assert((nSock = socket(AF_INET, SOCK_STREAM, 0)) > 0); memset(&addrin, 0, sizeof(addrin));
addrin.sin_family = AF_INET; addrin.sin_addr.s_addr = inet_addr(pAddr); addrin.sin_port = htons(nPort); if(connect(nSock, (struct sockaddr*)&addrin, sizeof(addrin)) >= 0){ *pSock = nSock; return 0; } else{ close(nSock); return 1; } }
[root@billstone Unix_study]# |
编译程序 , 分别连接百度网站上的已打开和未打开的端口 , 结果如下
[root@billstone Unix_study]# gcc -o tcpto1 tcpto1.c [root@billstone Unix_study]# ping www.baidu.com PING www.a.shifen.com (202.108.22.5) 56(84) bytes of data.
--- www.a.shifen.com ping statistics --- 1 packets transmitted, 0 received, 100% packet loss, time 0ms
[root@billstone Unix_study]# ./tcpto1 202.108.22.5 80 // 连接存在的 80 HTTP 端口 Connect Success. [root@billstone Unix_study]# ./tcpto1 202.108.22.5 1234 // 连接不存在的 1234 端口 Connect Timeout. // 延迟 3 秒 [root@billstone Unix_study]# |
多路复用并发模型
多路复用函数 select 把一些文件描述符集合在一起 , 如果某个文件描述符的状态发生编号 , 比如进入 ” 写就绪 ” 或者 ” 读就绪 ” 状态 , 函数 select 会立即返回 , 并且通知进程读取或写入数据 ; 如果没有 I/O 到达 , 进程将进入阻塞 , 知道函数 select 超时退出为止 .
利用多路复用 , 进程可以同时监控多个套接字信息 , 在多个套接字上并发地执行操作 .
几种常见的使用 select 的套接字进程如下 :
(1) 交互式进程
程序一边处理客户的交互式输入输出 , 一边使用套接字 . 多路复用标准输入 , 标准输出和套接字文件描述符 .
(2) 多套接字进程
程序同时使用侦听套接字和大量的连接套接字 .
(3) 多协议进程
程序同时使用 TCP 套接字和 UDP 套接字
(4) 多服务进程
程序同时应用多种服务 , 完成多种应用协议 , 比如 inetd 守护进程等 .
下面以多路复用读套接字文件描述符为例 , 说明并发程序设计的步骤 :
(1) 创建套接字文件描述符集合
fd_set fdset; // 文件描述符集合 FD_ZERO(&fdset); // 清空集合中的元素 FD_SET(nLisSock, &fdset); // 监控侦听套接字 FD_SET(nSockVal, &fdset); // 监控连接套接字 |
(2) 准备超时时间
Struct timeval wait; // 定义超时时间 wait.tv_sec = 0; // 超时时间为 0.1 秒 wait.tv_usec = 100000; |
(3) 调用函数 select 并检测应答结果
假设只复用读集合中的套接字描述符 , 其中 MAXSOCK 是集合 fdset 中最大的描述符号 .
Int ret; ret = select(MAXSOCK+1, &fdset, NULL, NULL, &wait); if (ret == 0) ... ... ... ... // 超时 else if (ret == -1) ... ... ... ... // 错误 else ... ... ... ... // 产生了套接字连接或数据发送请求 |
(4) 检测套接字文件描述符的状态并处理之
如果是侦听套接字 , 其操作流程一般为 :
If (FS_ISSET(nLisSock, &fdset)) { // 侦听套接字 , 处理连接申请 if ((nSockVal = accept(nLisSock, NULL, NULL)) > 0) ... ... ... ... } |
如果是连接套接字操作 , 其流程一般为 :
If (FS_ISSET(nLisSock, &fdset)) { // 连接套接字 , 读取传输数据 read (nSockVar[i], buf, sizeof(buf)); } |
采用 select 实现套接字的并发处理 , 有如下优点 :
(1) 在监控套接字描述符状态变化的过程中 , 函数 select 以阻塞的方式执行 , 这样可以节省 CPU 时间 .
(2) 当规定的时间到达后 , 套接字仍然没有连接申请 , 接收或发送 , 函数 select 将自动返回 , 这样可以预防进程一直阻塞下去 .
(3) 函数 select 能够同时监控多个套接字描述符的状态 , 实现套接字的并发处理 .
多进程并发模型
服务器套接字进程经常既需要接收客户端的连接申请 , 又要接收客户端发送的数据信息 , 遗憾的是函数 accept 和函数 recv 或 read 都会引起进程阻塞 , 服务器宽进程常常顾此失彼 .
多进程方法正好可以弥补这个缺陷 , 它创建专门的进程来处理每一个阻塞的套接字函数 , 比如父进程只执行函数 accept 等待并完成客户端的连接申请 , 子进程则执行函数 recv 等待客户端的信息发送 . 虽然每个进程都处于阻塞状态 , 但一旦某个套接字描述符的状态发送变化时 , 它所在的进程都能在第一时间被激活并完成响应操作 , 这样救灾整个进程组中实现了套接字的并发处理 .
(1) 不固定进程数的并发模型
多进程实现套接字并发处理最常见的方式是 accept 后创建子进程 , 父进程继续 accept, 子进程完成后续工作 .
不固定进程数的并发模型的服务器端流程如下 :
a. 创建侦听套接字 nLisSock (socket, bind 和 listen)
b. 进程转后台运行
c. 等待客户端的连接申请 (accept), 创建与客户端的通信连接 nSock
d. 创建子进程
e. 父进程关闭套接字 nSock, 此时用于子进程仍然打开此套接字 , 故父进程的关闭操作并不真正断开连接 , 只是把连接上减少 1
f. 父进程回到步骤 c 继续进行
g. 子进程关闭侦听套接字 nLisSock, 用于父进程仍然打开着侦听套接字 , 故实际上此套接字仅仅把连接数减少 1, 并不真正关闭它
h. 子进程执行 recv 和 send 等操作与客户端进行数据交换
i. 数据交换完毕 , 关闭套接字 , 子进程结束
(2) 固定进程数的并发模型
固定进程数的并发模型是一种介于单进程与多进程之间的这种方案 , 服务器父进程在创建侦听套接字后 fork 子进程 , 由子进程等待客户端的 connect 并完成与客户端的通信交换等工作 , 父进程的功能只是维持子进程的数量不变 .
1) 父进程流程
固定进程数并发模型的服务器端父进程的流程如下 :
a. 创建侦听套接字 nLisSock
b. 进程转后台运行
c. 创建子进程
d. wait 子进程 . 如果有子进程退出 , 则立即创建子进程 , 保证子进程在数量上不变 .
2) 子进程流程
子进程继续父进程的侦听套接字 , 按下面流程运行 :
a. 等待客户端的连接申请 (accept), 并与客户端建立通信套接字 nSock 连接
b. 执行 recv 和 send 等操作与客户端进行数据交换
c. 数据交换完毕 , 关闭套接字 nSock
d. 回到步骤 a, 继续执行