首页
社区
课程
招聘
[原创]《精通UNIX下C语言编程及项目实践》学习笔记05 网络通信篇
2009-4-30 09:55 8628

[原创]《精通UNIX下C语言编程及项目实践》学习笔记05 网络通信篇

2009-4-30 09:55
8628
欢迎转载,请保留作者信息
bill@华中科技大学
http://billstone.cublog.cn

第五篇 网络通信篇

  IPC对象只能实现在一台主机中的进程相互通信, 网罗通信对象则打破了这个限制, 它如同电话和邮件, 可以帮助不通屋檐下的人们相互交流.

  套接字(Socket)是网络通信的一种机制, 它已经被广泛认可并成为事实上的工业标准.

第十五章 基于TCP的通信程序

  TCP是一种面向连接的网络传输控制协议. 它每发送一个数据, 都要对方确认, 如果没有接收到对方的确认, 就将自动重新发送数据, 直到多次重发失败后,才放弃发送.

  套接字的完整协议地址信息包括协议, 本地地址, 本地端口, 远程地址和远程端口等内容, 不同的协议采用不同的结构存储套接字的协议地址信息.

  Socket是进程间的一个连接, 我们可以采用协议, 地址和端口的形式描述它:

  { 协议, 本地地址, 本地端口, 远程地址, 远程端口 }

  当前有三种常见的套接字类型, 分别是流套接字(SOCK_STREAM), 数据报套接字(SOCK_DGRAM)和原始套接字(SOCK_RAW):

  1) 流套接字. 提供双向的, 可靠的, 顺序的, 不重复的, 面向连接的通信数据流. 它使用了TCP协议保真了数据传输的正确性.

  2) 数据报套接字. 提供一种独立的, 无序的, 不保证可靠的无连接服务. 它使用了UDP协议, 该协议不维护一个连接, 它只把数据打成一个包, 再把远程的IP贴上去, 然后就把这个包发送出去.

  3) 原始套接字. 主要应用于底层协议的开发, 进行底层的操作.

  TCP协议的基础编程模型

  TCP是面向连接的通信协议, 采用客户机-服务器模式, 套接字的全部工作流程如下:

  首先, 服务器端启动进程, 调用socket创建一个基于TCP协议的流套接字描述符.

  其次, 服务进程调用bind命名套接字, 将套接字描述符绑定到本地地址和本地端口上, 至此socket的半相关描述----{协议, 本地地址, 本地端口}----完成.

  再次, 服务器端调用listen, 开始侦听客户端的socket连接请求.

  接下来, 客户端创建套接字描述符, 并且调用connect向服务器提交连接请求. 服务器端接收到客户端连接请求后, 调用accept接受, 并创建一个新的套接字描述符与客户端建立连接, 然后原套接字描述符继续侦听客户端的连接请求.

  客户端与服务器端的新套接字进行数据传送, 调用write或send向对方发送数据, 调用read或recv接收数据.

  在数据交流完毕后, 双方调用close或shutdown关闭套接字.

  (1) Socket的创建

  在UNIX中使用函数socket创建套接字描述符, 原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

  其中, 参数domain指定发送通信的域, 有两种选择: AF_UNIX, 本地主机通信, 功能和IPC对象类似; AF_INET, Internet地址IPV4协议. 在实际编程中, 我们只使用AF_INET协议, 如果需要与本地主机进程建立连接, 只需把远程地址设定为'127.0.0.1'即可.

  参数type指定了通信类型: SOCK_STREAM, SOCK_DGRAM和SOCK_RAW. 协议AF_INET支持以上三种类型, 而协议AF_UNIX不支持原始套接字.

  (2) Socket的命名

  函数bind命名一个套接字, 它为该套接字描述符分配一个半相关属性, 原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);

  参数s指定了套接字描述符, 该值由函数socket返回, 指针name指向通用套接字的协议地址结构, namelen参数指定了该协议地址结构的长度.

  结构sockaddr描述了通用套接字的相关属性, 结构如下
typedef unsigned short int sa_family_t;
#define __SOCKADDR_COMMON(sa_prefix)  sa_family_t sa_prefix##family
struct sockaddr{
    __SOCKADDR_COMMON (sa_);    /* Common data: address family and length.  */
    char sa_data[14];           /* Address data.  */
};

  不同的协议有不同的地址描述方式, 为了便于编码处理, 每种协议族都定义了自给的套接字地址属性结构, 协议族AF_INET使用结构sockaddr_in描述套接字地址信息, 结构如下:
struct sockaddr_in{
    __SOCKADDR_COMMON (sin_);
    in_port_t sin_port;                 /* Port number.  */
    struct in_addr sin_addr;            /* Internet address.  */
                                   /* Pad to size of `struct sockaddr'.  */
    unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE -   \
                           sizeof (in_port_t) - sizeof (struct in_addr)];
};
typedef uint32_t in_addr_t;
struct in_addr{
    in_addr_t s_addr;
};

  这里有两点需要注意:

  a. IP地址转换

  在套接字的协议地址信息结构中, 有一个描述IP地址的整型成员. 我们习惯使用点分方式描述IP地址, 所以需要将其转化为整型数据, 下列函数完成此任务
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);

  函数inet_addr将参数ptr指向的字符串形式IP地址转换为4字节的整型数据. 函数inet_aton同样完成此功能. 函数inet_ntoa的功能则恰好相反.

  b. 字节顺序转换

  网络通信常常跨主机, 跨平台, 跨操作系统, 跨硬件设备, 但不同的CPU硬件设备, 不同的操作系统对内存数据的组织结构不尽相同. 在网络通信中, 不同的主机可能采取了不同的记录顺序, 如果不做处理, 通信双方对相同的数据会有不同的解释. 所以需要函数实现主机字节顺序和网络字节顺序的转换
#include <netinet/in.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

  函数htons, htonl分别将16位和32位的整数从主机字节顺序转换为网络字节顺序.

  函数ntohs, ntohl分别将16位和32位的整数从网络字节顺序转换为主机字节顺序.

  (3) Socket的侦听

  TCP的服务器端必须调用listen才能使套接字进入侦听状态, 原型如下
#include <sys/socket.h>
int listen(int s, int backlog);

  参数s是调用socket创建的套接字. 参数backlog则确定了套接字s接收连接的最大数目.

  在TCP通信模型中, 服务器端进程需要完成创建套接字, 命名套接字和侦听接收等一系列操作才能接收客户端连接请求. 下面设计了一个封装了以上三个操作的函数, 代码如下
int CreateSock(int *pSock, int nPort, int nMax){
        struct sockaddr_in addrin;
        struct sockaddr *paddr = (struct sockaddr *)&addrin;
        int ret = 0;        // 保存错误信息
 
        if(!((pSock != NULL) && (nPort > 0) && (nMax > 0))){
                 printf("input parameter error");
                 ret = 1;
        }
        memset(&addrin, 0, sizeof(addrin));
       
        addrin.sin_family = AF_INET;
        addrin.sin_addr.s_addr = htonl(INADDR_ANY);
        addrin.sin_port = htons(nPort);
                          // 创建socket, 在我本机上是5
        if((ret == 0) && (*pSock = socket(AF_INET, SOCK_STREAM, 0)) <= 0){
                 printf("invoke socket error\n");
                 ret = 1;
        }
                              // 绑定本地地址
        if((ret == 0) && bind(*pSock, paddr, sizeof(addrin)) != 0){
                 printf("invoke bind error\n");
                 ret = 1;
        }
                
        if((ret == 0) && listen(*pSock, nMax) != 0){
                 printf("invoke listen error\n");
                 ret = 1;
        }
       
        close(*pSock);
        return(ret);
}

  (4) Socket的连接处理

服务器端套接字在进入侦听状态后, 通过accept接收客户端的连接请求
#include <sys/types.h>
#include <sys/socket.h>
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);

  函数accept一旦调用成功, 系统将创建一个属性与套接字s相同的新的套接字描述符与客户进程通信, 并返回该新套接字的描述符编号, 而原套接字s仍然用于套接字侦听. 参数addr回传连接成功的客户端地址结构, 指针addrlen回传该结构占用的字节空间大小.

  下面封装了系统调用accept, 代码如下
#include <fcntl.h>
int AcceptSock(int *pSock, int nSock){
        struct sockaddr_in addrin;
        int lSize, flags;
       
        if((pSock == NULL) || (nSock <= 0)){
                 printf("input parameter error!\n");
                 return 2;
        }
        flags = fcntl(nSock, F_GETFL, 0);          // 通过fcntl函数确保nSock处于阻塞方式
        fcntl(nSock, F_SETFL, flags & ~O_NONBLOCK);
        while(1){
                 lSize = sizeof(addrin);
                 memset(&addrin, 0, sizeof(addrin));       // 通过调试, 问题应该出在accept函数
                 if((*pSock = accept(nSock, (struct sockaddr *)&addrin, &lSize)) > 0)
                          return 0;
                 else if(errno == EINTR)
                          continue;
                 else{
                          fprintf(stderr, "Error received! No: %d\n", errno);
                          return 1;
                 }
        }
}

  (5) Socket的关闭

套接字可以调用close函数关闭, 也可以调用下面函数
#include <sys/socket.h>
int shutdown(int s, int how);

  函数shutdown是强制性地关闭所有套接字连接, 而函数close只将套接字访问计数减1, 当且仅当计数器值为0时, 系统才真正的关闭套接字通信.

  (6) Socket的连接申请

  TCP客户端调用connect函数向TCP服务器端发起连接请求, 原型如下
#include <sys/types.h>
#include <sys/socket.h>
int  connect(int  sockfd,  const  struct sockaddr *serv_addr, socklen_t addrlen);

  其中, serv_addr指针指定了对方的套接字地址结构.

  (7) TCP数据的发送和接收

  套接字一旦连接上, 就可以发送和接收数据. 原型如下
#include <sys/types.h>
#include <sys/socket.h>
int send(int s, const void *msg, size_t len, int flags);
int recv(int s, void *buf, size_t len, int flags);

  函数send(recv)应用于TCP协议的套接字通信中, s是与远程地址连接的套接字描述符, 指针msg指向待发送的数据信息(或接收数据的缓冲区), 此信息共len个字节(或最大可接收len个字节).

  如果函数send一次性发送的信息过长, 超过底层协议的最大容量, 就必须分开调用send发送, 否则内核将不予发送信息并且置EMSGSIZE错误.

  简单服务器端程序

  这里设计了一个TCP服务器端程序的实例, 它创建Socket侦听端口, 与客户端建立连接, 然后接收并打印客户端发送的数据,代码如下
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <errno.h>
 
#define VerifyErr(a, b) \
        if (a) { fprintf(stderr, "%s failed.\n", (b)); return 0; } \
        else fprintf(stderr, "%s success.\n", (b));
 
int main(void)
{
        int nSock, nSock1;
        char buf[2048];
 
        //CreateSock(&nSock, 9001, 9);
        nSock = nSock1 = 0;               // 这里只是为了调试所用
        VerifyErr(CreateSock(&nSock, 9001, 9) != 0, "Create Listen SOCKET");
        //VerifyErr(AcceptSock(&nSock1, nSock) != 0, "Link");
        AcceptSock(&nSock1, nSock);
        memset(buf, 0, sizeof(buf));
        recv(nSock1, buf, sizeof(buf), 0);
        fprintf(stderr, buf);
        close(nSock1);
        close(nSock);
 
        return 0;
}

  运行程序, 并在在浏览器中输入http://127.0.0.1:9001/, 你将得到一条来自客户端的http报文. 但是在这里却出现了问题, 问题如下:
[bill@billstone Unix_study]$ make tcp1
cc     tcp1.c   -o tcp1
[bill@billstone Unix_study]$ ./tcp1
Create Listen SOCKET success.
Error received! No: 9                         // 出现错误, 为'bad file number'错误
[bill@billstone Unix_study]$

  请大家帮忙查找一下原因!

[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

收藏
点赞7
打赏
分享
最新回复 (8)
雪    币: 339
活跃值: (10)
能力值: ( LV9,RANK:260 )
在线值:
发帖
回帖
粉丝
billstone 6 2009-4-30 09:58
2
0
欢迎大家帮忙查找原因
雪    币: 339
活跃值: (10)
能力值: ( LV9,RANK:260 )
在线值:
发帖
回帖
粉丝
billstone 6 2009-4-30 21:24
3
0
有使用linux的请帮忙测试一下上面的服务器程序
雪    币: 2108
活跃值: (21)
能力值: (RANK:260 )
在线值:
发帖
回帖
粉丝
书呆彭 6 2009-4-30 22:14
4
0
先看看这里accept的manpage

The argument sockfd is a socket that has been created with socket(2), bound to a local address with bind(2), and is listening for connections after a listen(2).


Error received! No: 9  说明accept调用传入的socket参数不正确,大概看了一下你的代码,发现你的CreateSocket函数后面把socket给关闭了:

int CreateSock(int *pSock, int nPort, int nMax){
struct sockaddr_in addrin;
struct sockaddr *paddr = (struct sockaddr *)&addrin;
int ret = 0; // 保存错误信息

if(!((pSock != NULL) && (nPort > 0) && (nMax > 0))){
printf("input parameter error");
ret = 1;
}
memset(&addrin, 0, sizeof(addrin));

addrin.sin_family = AF_INET;
addrin.sin_addr.s_addr = htonl(INADDR_ANY);
addrin.sin_port = htons(nPort);
// 创建socket, 在我本机上是5
if((ret == 0) && (*pSock = socket(AF_INET, SOCK_STREAM, 0)) <= 0){
printf("invoke socket error\n");
ret = 1;
}
// 绑定本地地址
if((ret == 0) && bind(*pSock, paddr, sizeof(addrin)) != 0){
printf("invoke bind error\n");
ret = 1;
}

if((ret == 0) && listen(*pSock, nMax) != 0){
printf("invoke listen error\n");
ret = 1;
}

close(*pSock);
return(ret);
}
雪    币: 339
活跃值: (10)
能力值: ( LV9,RANK:260 )
在线值:
发帖
回帖
粉丝
billstone 6 2009-4-30 22:20
5
0
to 书呆彭:

  知道了,我试试。
雪    币: 339
活跃值: (10)
能力值: ( LV9,RANK:260 )
在线值:
发帖
回帖
粉丝
billstone 6 2009-4-30 22:24
6
0
确实是关闭了套接字的原因,下面做了简单修改(红色标记)

int CreateSock(int *pSock, int nPort, int nMax){
struct sockaddr_in addrin;
struct sockaddr *paddr = (struct sockaddr *)&addrin;
int ret = 0;

if(!((pSock != NULL) && (nPort > 0) && (nMax > 0))){
printf("input parameter error");
ret = 1;
}
memset(&addrin, 0, sizeof(addrin));

addrin.sin_family = AF_INET;
addrin.sin_addr.s_addr = htonl(INADDR_ANY);
addrin.sin_port = htons(nPort);

if((ret == 0) && (*pSock = socket(AF_INET, SOCK_STREAM, 0)) <= 0){
printf("invoke socket error\n");
ret = 1;
}
if((ret == 0) && bind(*pSock, paddr, sizeof(addrin)) != 0){
printf("invoke bind error\n");
ret = 1;
}
if((ret == 0) && listen(*pSock, nMax) != 0){
printf("invoke listen error\n");
ret = 1;
}
if(ret == 1)
close(*pSock);


return(ret);
}

测试结果如下:
[bill@billstone Unix_study]$ make tcp1
cc     tcp1.c   -o tcp1
[bill@billstone Unix_study]$ ./tcp1
Create Listen SOCKET success.                        // 此时在浏览器输入http://127.0.0.1:9001/
GET / HTTP/1.1
Host: 127.0.0.1:9001
User-Agent: Mozilla/5.0 (X11; U; Linux i686; zh-CN; rv:1.2.1) Gecko/20030225
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,video/x-mng,image/png,image/jpeg,image/gif;q=0.2,text/css,*/*;q=0.1
Accept-Language: zh-cn, zh;q=0.50
Accept-Encoding: gzip, deflate, compress;q=0.9
Accept-Charset: GB2312, utf-8;q=0.66, *;q=0.66
Keep-Alive: 300
Connection: keep-alive
 
[bill@billstone Unix_study]$
雪    币: 339
活跃值: (10)
能力值: ( LV9,RANK:260 )
在线值:
发帖
回帖
粉丝
billstone 6 2009-5-3 22:10
7
0
第十六章 基于UDP的通信程序设计

  UDP协议使用函数sendto发送数据, 使用函数recvfrom接收数据.

  函数sendto的原型如下
#include <sys/types.h>
#include <sys/socket.h>
int  sendto(int s, const void *msg, size_t len, int flags, const struct sockaddr *to, socklen_t tolen);

  通过函数sendto发送数据时总能立即成功返回. 注意, 这里'成功'的含义是指数据被成功的发送, 并不保证对方套接字能成功的接收, 甚至不保证对方套接字协议信息的正确性.

  函数recvfrom的原型如下
#include <sys/types.h>
#include <sys/socket.h>
int  recvfrom(int  s, void *buf, size_t len, int flags, struct sockaddr *from, socklen_t *fromlen);

  函数recvfrom默认以阻塞方式读取数据, 成功时返回接收数据的字节长度. 成功时返回0, 否则返回-1并置errno.

  UDP协议的基础编程模型

  UPD是无连接的通信协议, 也采用客户机-服务器模式, 几个关键步骤如下:

  (1) 创建UDP服务器套接字

  在UDP通信模型中,服务器端进程需要完成创建套接字, 命名套接字, 等操作后才能调用接收客户端的数据, 这里整合成一个函数CreateUdpSock.
int CreateUdpSock(int *pnSock, int nPort){
        struct sockaddr_in addrin;
        struct sockaddr *paddr = (struct sockaddr *)&addrin;
 
        assert(pnSock != NULL && nPort > 0);
        memset(&addrin, 0, sizeof(addrin));
        addrin.sin_family = AF_INET;
        addrin.sin_addr.s_addr = htonl(INADDR_ANY);
        addrin.sin_port = htons(nPort);
 
        assert((*pnSock = socket(AF_INET, SOCK_DGRAM, 0)) > 0);
        if(bind(*pnSock, paddr, sizeof(addrin)) >= 0)
                 return 0;
        close(*pnSock);
 
        return 1;
}

  其中, 参数pSock回传创建的侦听套接字描述符, 整型nPort指定了套接字的侦听端口. 调用成功时返回0, 否则关闭套接字并返回其他值.

  (2)发送UDP协议数据

  这里设计了一个封装了sendto系统调用的函数SendMsgByUdp.
int SendMsgByUdp(void *pMsg, int nSize, char *szAddr, int nPort){
        int nSock, ret;
        struct sockaddr_in addrin;
 
        assert(pMsg != NULL && nSize > 0 && szAddr != NULL && nPort > 0);
 
        assert((nSock = socket(AF_INET, SOCK_DGRAM, 0)) > 0);
        memset(&addrin, 0, sizeof(struct sockaddr));
        addrin.sin_family = AF_INET;
        addrin.sin_addr.s_addr = inet_addr(szAddr);
        addrin.sin_port = htons(nPort);
        if(sendto(nSock, pMsg, nSize, 0, (struct sockaddr *)&addrin, sizeof(addrin)) > 0)
                 ret = 0;
        else
                 ret = 1;
        close(nSock);
 
        return(ret);
}

  该函数向目的地址发送UDP数据包, 其中指针pMsg指向待发送的数据, 该数据长nSize字节, 目的地址的IP是szAddr, 目的端口是nPort. 调用成功时返回0, 否则返回其他值.

  (3) 接收UDP协议数据

  这里同样封装了系统调用recvfrom, 函数名为RecvMsgByUdp.
int RecvMsgByUdp(int nFile, void *pData, int *pnSize){
        int nSize;
 
        assert(nFile > 0);
        if((*pnSize = recvfrom(nFile, pData, *pnSize, 0, NULL, NULL)) > 0)
                 return 0;
        else
                 return 1;
}

  其中, nFile是服务器端的UDP套接字描述符, 指针pData指向接收数据的内存缓冲区, 参数pnSize是该缓冲区可容纳的最大容量. 调用成功时返回0, 并将接收到的数据存储在pData中, 参数pnSize回传接收到的字节长度; 否则返回其他值.

  (4) 实例

  这里设计一个基于UDP的客户端-服务器端的例子. 客户端进程每隔一秒向服务器发送数据报信息. 服务器进程先创建端口号为9999的UDP套接字, 然后循环接收数据报信息并打印在屏幕上.

  这里, 为了编程方便我们定义了一个头文件udp.h封装了上面三个函数.
[bill@billstone Unix_study]$ cat udp.h
#include <stdio.h>
#include <assert.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>
 
int CreateUdpSock(int *pnSock, int nPort);
int SendMsgByUdp(void *pMsg, int nSize, char *szAddr, int nPort);
int RecvMsgByUdp(int nFile, void *pData, int *pnSize);
[bill@billstone Unix_study]$

  UDP客户端程序如下
[bill@billstone Unix_study]$ cat udpk1.c
#include "udp.h"
 
int main(void)
{
        int ret, i = 0;
        char szBuf[100];
 
        while(1){
                sprintf(szBuf, "第%d次发送", i);
                ret = SendMsgByUdp(szBuf, strlen(szBuf), "127.0.0.1", 9999);
                if(ret == 0)
                        printf("Send UDP Success: %s\n", szBuf);
                else
                        printf("Send UDP Failed: %s\n", szBuf);
 
                sleep(1);
                i++;
        }
}

[bill@billstone Unix_study]$
  UDP服务器端程序如下
[bill@billstone Unix_study]$ cat udps1.c
#include "udp.h"
 
int main(void)
{
        int nSock, nSize;
        char szBuf[256];
 
        CreateUdpSock(&nSock, 9999);
        nSize = sizeof(szBuf);
        memset(szBuf, 0, sizeof(szBuf));
        while(RecvMsgByUdp(nSock, szBuf, &nSize) == 0){
                printf("Recv UDP Data: [%d] [%s]\n", strlen(szBuf), szBuf);
                nSize = sizeof(szBuf);
                memset(szBuf, 0, sizeof(szBuf));
        }
 
        close(nSock);
 
        return 0;
}
[bill@billstone Unix_study]$

  首先运行客户端程序udpk1
[bill@billstone Unix_study]$ gcc -o udpk1 udpk1.c udp.c
[bill@billstone Unix_study]$ ./udpk1
Send UDP Success: 第0次发送
Send UDP Success: 第1次发送
Send UDP Success: 第2次发送
Send UDP Success: 第3次发送
Send UDP Success: 第4次发送      // 此时服务器程序才开始执行并实际接收数据
Send UDP Success: 第5次发送
Send UDP Success: 第6次发送
Send UDP Success: 第7次发送
Send UDP Success: 第8次发送
Send UDP Success: 第9次发送

  接着在另一个终端运行服务器程序udps1
[bill@billstone Unix_study]$ gcc -o udps1 udps1.c udp.c
[bill@billstone Unix_study]$ ./udps1
Recv UDP Data: [9] [第4次发送]
Recv UDP Data: [9] [第5次发送]
Recv UDP Data: [9] [第6次发送]
Recv UDP Data: [9] [第7次发送]
Recv UDP Data: [9] [第8次发送]
雪    币: 2108
活跃值: (21)
能力值: (RANK:260 )
在线值:
发帖
回帖
粉丝
书呆彭 6 2009-5-4 13:03
8
0
又更新了,支持一个。
雪    币: 339
活跃值: (10)
能力值: ( LV9,RANK:260 )
在线值:
发帖
回帖
粉丝
billstone 6 2009-5-8 19:43
9
0
十七章 并发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, 继续执行
游客
登录 | 注册 方可回帖
返回