十七章 并发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, 继续执行