写在开始之前
在开始之前,达达有一些题外话想先跟大家说说。
在阅读这一系列的文章时,我希望大家始终记住以下几点:
1. 软件开发没有银弹,人们总是试图找到问题的唯一解和最优解,但事实是每个问题都有N种解,并且在不同情况下最优解是不一样的,如果非要说软件开发有银弹,那么这颗银弹就是人的心,是否找能到最优解,在于你是否能把握住了所有事情的平衡点。所以,请不要说某某机制最好、某某算法最优、某某架构万能、不需要再了解其他了。也请不要自以为目空一切技术,商业和盈利至上,实现途径和方式无所谓。请抱着一切皆有可能的心态看待所有事物,才有更多机会看到平衡点。
2. 语言、平台、API只是迷人眼的东西,它们好像什么都是,其实什么都不是。解决问题的关键在于设计者的心,设计者是否对要解决的问题和问题的上下文了然于心。不要把学习语言、学习平台、学习API当作目标,它们只是泥沙和工具,最终我们要建造的是房屋,所以请把目标放得更远。也不要把它们当成阻碍,因为它们生来就是要让人使用的东西,不可能形成阻碍,如果你觉得它们是阻碍,那实际上那个阻碍只在你心里。
3. 记得:Do one thing, and do it well。一次只做一件事,并且做好它。这一系列文章的基本开发和测试环境是Linux,编译器是gcc。如果你之前对Linux不是很熟悉,我建议你安装一个VMware,并安装Ubuntu桌面系统,然后apt-get install build-essential,这样你的系统里就有完整的开发环境了,gnome自带的gedit很好用,我也是这么做的。不要一上来就Linux命令行界面加vi编辑器,没必要为难自己也不要搞得自己像黑客一样。请记住,我们当前要做的是高性能socket服务器,只做这一件事,并且做好它!不是研究Linux命令行或者vi编辑器,那些等有空再慢慢研究还来的及。
4. 师傅请进门修行靠个人。文章和教程的内容其实都是转之由转的东西,如果要了解原汁原味的内容,首选应该去阅读操作系统的代码,其次是系统文档,再次才是网络教程。而经验是世界上最难传达的东西,文字只能让你形成记忆,不能让你获得经验,它只是像买彩票一样给你提供一个机会,让在你实践过程中可能会有那么一下的灵光一现,然后得出自己的结论,那才是你的真正经验。如果把生活比喻成RPG,造物主怎么可能让经验可以在玩家之间传递呢?那不是乱了套了,打RPG我们可以学各种技能,但是要得到经验就得打怪做任务,生活其实也是一样的道理。
好了废话就到此结束。
让我们开始吧!
别急,别急,勿在浮沙筑高台~~!
要开始建造我们的高性能socket服务器大厦之前,还是让我们先从泥水匠做起吧,先来了解以下泥沙和工具吧。
记得前面说的吗?一次只做一件事,并且做好它。现在我们就抛开所有杂念和对高性能socket服务器的各种猜想,先做一个最基本的socket服务器端程序。
等我们逐步熟悉了泥沙和工具,我们再杀回来逐个干掉高深莫测的服务器架构设计,这就是我们的行动计划。
这里先贴出本章的示例代码,我再根据这个代码跟大家逐步讲解socket编程的关键知识点:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define SD_PORT 10086
#define SD_BACK_LOG 10
int sd_listener_fd;
void
sd_init ()
{
int reuse = 1;
struct sockaddr_in addr;
if ((sd_listener_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("Create listener socket failed");
exit(-1);
}
if (setsockopt(sd_listener_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1)
{
perror("Setup listener socket failed");
exit(-1);
}
bzero(&(addr.sin_zero), 8);
addr.sin_family = AF_INET;
addr.sin_port = htons(SD_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(sd_listener_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1)
{
perror("Bind listener socket address failed");
exit(-1);
}
if (listen(sd_listener_fd, SD_BACK_LOG) == -1)
{
perror("Listen port failed");
exit(-1);
}
}
void
sd_loop ()
{
char buf[1024];
int ret = 0;
int client_fd;
int client_addr_len;
struct sockaddr_in client_addr;
printf("Waiting connect on port %dn", SD_PORT);
client_addr_len = sizeof(client_addr);
client_fd = accept(sd_listener_fd, (struct sockaddr *)&client_addr, &client_addr_len);
printf("Client connectedn");
for (;;)
{
if ((ret = read(client_fd, buf, 1024)) == 0)
{
close(client_fd);
printf("Client closedn");
break;
}
else
{
write(client_fd, buf, ret);
}
}
}
void
sd_down ()
{
close(sd_listener_fd);
printf("Server shutdownn");
}
int
main (int argc, char *argv[])
{
sd_init();
sd_loop();
sd_down();
return 1;
}
上面的代码是一个简单的echo服务器,它一次只处理一个连接,在客户端退出时服务器端也跟着关闭,你可以复制上面的代码保存为socketd.c,然后打开终端,切换到文件所在目录,输入:
cc socketd.c -o socketd
不出意外的话,我们的最原始版socket服务器就编译好了。然后输入:
./socketd
服务器就启动了。另外再开一个终端,输入:
telnet localhost 10086
这时候telnet应该能连上socket服务器,你可以在telnet里面输入一些文字,然后回车,服务器应该会将你发送的内容原样返回。
当你玩腻了,就在telnet界面按住Ctrl键,然后输入“]“,回车。这时候telent会切换到命令界面,输入q,回车,退出telent。
telnet退出后,相当于客户端断开了连接,按代码逻辑上面的示例程序应该会跟着退出。
顺便说一下,像上面示例这样接受并原样返回客户端请求内容的socket服务器叫做echo服务器,名字谁取的我不知道,反正大家都这么叫。 :)
下面我们来分析一下这段代码,我们从大结构分析入手,再深入到每个函数的介绍。
阅读这段代码要从main函数开始,main函数逐步调用了三个sd_开头的函数,sd_init() 初始化服务器 -> sd_loop() 服务器循环处理请求 -> sd_down() 服务器关闭。
sd_init 函数中的代码是典型的服务器端socket初始化过程,socket() 创建套接字 -> bind() 绑定地址 -> listener() 开始监听端口。
sd_loop 函数中的代码则是一个简单的接收客户端请求并回发数据的示例,accept() 接受新连接 -> read() 接受请求数据 -> write()发送数据。
sd_down 函数中的代码演示了如何关闭套接字,close() 就这么简单。
下面我以函数注释的方式一一注释上面代码涉及到的系统函数,这样可以不需要附加太多废话的描述并条理清晰。
/*
* 功能:创建socket
* 返回:成功时,返回socket文件描述符;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* domain - 地址种类,较常用的有AF_INET和AF_INET6,分别对应IPv4协议和IPv6协议
* type - 套接字类型,较常用的有SOCK_STREAM和SOCK_DGRAM,分别对应TCP/IP协议和UDP协议
* protocol - 协议,一些特殊的套接字类型下可能会用到,但是做TCP或者UDP编程时不会用到此参数,所以我们通常传递0
*/
int socket(int domain, int type, int protocol);
/*
* 功能:将socket绑定到指定的地址
* 返回:成功时,返回0;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* sockfd - 套接字文件描述符,就是socket函数成功时返回的那个
* addr - 所要绑定到的地址,其中包含地址种类、协议族IP地址和端口号,IP地址和端口号在赋值时分别需要用htonl和htons函数进行大小端转换
* addrlen - 地址长度,因为我们通常用的是sockaddr_in类型地址,所以这个参数就是sizeof(struct sockaddr_in)
*/
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
/*
* 功能:监听套接字上的连接
* 返回:成功时,返回0;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* sockfd - 套接字文件描述符,就是socket函数成功时返回的那个
* backlog - 等待连接完成的队列大小,当服务器繁忙时可能没办法一次响应所有连接请求,
* 这时候连接请求会被放入队列等待处理,队列满的时候,客户端才真正无法连接
*/
int listen(int sockfd, int backlog);
/*
* 功能:接受一个新的连接
* 返回:成功时,返回新连接的socket文件描述符;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* sockfd - 监听的套接字文件描述符,就是listen函数用的那个
* addr - 新连接的地址信息(指针返回)
* addrlen - 新连接的地址长度(指针返回)
*/
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);/*
* 功能:创建socket
* 返回:成功时,返回socket文件描述符;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* domain - 地址种类,较常用的有AF_INET和AF_INET6,分别对应IPv4协议和IPv6协议
* type - 套接字类型,较常用的有SOCK_STREAM和SOCK_DGRAM,分别对应TCP/IP协议和UDP协议
* protocol - 协议,一些特殊的套接字类型下可能会用到,但是做TCP或者UDP编程时不会用到此参数,所以我们通常传递0
*/
上面的函数介绍并不是最详细也不是最权威的,我建议大家不妨在命令行下面用man命令查阅各个函数的详细文档,用法是man [函数名]。在bind之前我们创建socket地址的时候,用到了htonl和htons函数:
/*
* 功能:Host to Network (long),将主机上的长整型数据进行大小端转换,以适应网络规范
*/
uint32_t htonl(uint32_t hostlong);
/*
* 功能:Host to Network (short),将主机上的短整型数据进行大小端转换,以适应网络规范
*/
uint16_t htons(uint16_t hostshort);
分别与这两个函数对应但没有出现在代码中的还有:
/*
* 功能:Network to Host (long),将网络规范格式的长整型数据进行大小端转换,以适应主机
*/
uint32_t ntohl(uint32_t netlong);
/*
* 功能:Network to Host (short),将网络规范格式的短整型数据进行大小端转换,以适应主机
*/
uint16_t ntohs(uint16_t netshort);
为什么数据需要进行大小端的转换呢?大小端转换又是什么呢?这就要涉及到计算机组织原理的知识了。
简单说来,我们的数据在计算机中是以二进制字节数据表示的,二进制字节数据保存在内存中时就涉及到一个实现问题,比如十进制数1,对应的字节是应该表示成1000 0000还是0000 0001呢?到底是高位在前还是低位在前对计算机来说是一个实现的问题,而我们平时阅读和书写的习惯是高位在前,所以成为大端模式,即0000 0001格式,而反之则称为小端格式。在不同厂商的CPU上,数据的存储格式是不一样的,比如IBM和SUN用的是大端格式,Intel用的则是小端格式。而当不同数据格式的主机,在网络间进行数据传输时,这个实现问题就演变成了兼容问题,而RFC规范中规定了网络通讯时的字节格式是大端格式,所以系统就提供了相应的转换函数提高程序兼容性。
对于大小端格式的详细信息,大家如果有兴趣了解可以在网上搜索,下面是一个关于大小端的有趣故事:
端模式(Endian)的这个词出自Jonathan Swift书写的《格列佛游记》。
这本书根据将鸡蛋敲开的方法不同将所有的人分为两类,从圆头开始将鸡蛋敲开的人被归为Big Endian,从尖头开始将鸡蛋敲开的人被归为Littile Endian。
小人国的内战就源于吃鸡蛋时是究竟从大头(Big-Endian)敲开还是从小头(Little-Endian)敲开。在计算机业Big Endian和Little Endian也几乎引起一场战争。
在计算机业界,Endian表示数据在存储器中的存放顺序。
示例代码中,在创建监听的套接字文件描述符后,还执行了这样一句代码:
setsockopt(sd_listener_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
setsockopt函数是用来设置socket的参数的,这些参数决定socket的一些表现,例如后面的章节中我们使用这个函数设置socket为无阻塞模式。而上面这行代码则是用来运行socket重用端口的,所以它必须执行在bind之前。这么设置为了防止程序在意外退出后,系统没有释放端口而导致程序无法再使用原来端口
本章的示例代码只需要走马观花看一遍,熟悉一下socket编程的大概流程,以后自己亲手实验的时候不记得怎么做了再回来查阅就可以。
没必要死记硬背这些API,只需要知到这些函数存在,它们大概干什么用的。盖楼嘛,你背各种泥沙学名和化学成分有什么用?只要懂得辨别,需要时能从手册找到查阅到具体信息就可以了。
本章总结
本章演示了一个简单的echo服务器,它只支持一次处理一个连接,并且在连接退出时,服务器端也跟着关闭。
通过这个简单的例子,我们学习了基本的socket初始化过程和连接的响应方式。
当然这些知识对于我们的远大目标“高性能socket服务器"来说是远远不够的,但是这些是基础的基础,至少我们已经迈开了第一步。
下一章我将向大家介绍如何利用IO重用,在一个进程中同时处理多个连接的请求,敬请期待。
------------------------------------------------------华丽分界线---------------------------------------------------------------------
上一章,我向大家演示了一个最基本的socket服务器结构,它一次只能响应一个连接请求,而“能同时响应多个连接和请求”无疑是现实生活中对socket服务器的最基本要求。要如何让socket服务器可以同时响应多个连接和请求呢?多线和多进程程肯定是大部分人首先想到的,可能很多人不一定真正清楚多线程和多进程的socket服务器架构具体意味着什么,但是至少大家都或多或少听说过这两种技术。不过本章中,我们暂时还不会涉及到多线程和多进程的服务器架构,我它们归类为设计范畴,而我们暂时还没有脱离泥水匠身份,所以还要继续学习“泥沙之用途“,设计的事情需要等到我们泥水匠毕业,升级建筑设计师的时候再说。
那么本章具体的内容是什么呢?真是没有悬念,在上一章中我已经提前透露了:IO重用。下面就正式进入主题吧。
什么是IO重用
我们先来想像这样一个场景:一个只有一个柜台一个营业员的银行营业厅。这个银行所有业务都需要填表格,而且如果填了表格,熟练的营业员能在一瞬间帮你把事情办完。在繁忙的时候,大家排成长队等待轮到自己,当排到的时候从营业员手中拿到表格,然后填写一番,接着交给营业员处理。很显然,这个营业厅很低效,低效在哪里呢?它有优秀的营业员,但是缺少合理的运作模式。实际上后面的人排队等待的时间不是业务处理时间,而是前面的人的填表事件。万一遇到需要连续办多件事情的客户,他堵在那里填了一份又一份表格,后面的人只能一直等着了。
我们怎么改进这个营业厅呢?我们可以在大厅设置一个自行领取表格和填写表格的桌子,让大家先填好了表格再到柜台办理业务,这样高效的营业员就能非常快速的处理业务,就几乎不可能出现排队了(注意,我说的是"几乎"不是绝对)。
而我们前一章演示的socket服务器程序,就像改进之前的银行营业厅,它的低效在于它没有充分利用IO,而不是它有复杂的业务逻辑。所以人们为了解决这类问题就在设计操作系统的时候加入了IO重用机制,让编程人员可以更有效的利用IO,就像提供了领取和填写表格的桌子一样。
IO重用技术有很多种,有些是夸平台的,有些是平*有的,这里就列举一些我知道的:
名称 平台
select Linux, *BSD, Mac OS X, Solaris, Windows
poll Linux, *BSD, Mac OS X
epoll Linux
/dev/poll Solaris
kqueue FreeBSD
IOCP Windows
每一种IO重用技术都是通过操作系统提供的一组特定的API函数调用来提供支持的,我们所要学的就是学会怎么用这些API并且了解每种技术背后的原理和来龙去脉。
本章就从最通用应该也是最早出现的IO重用技术select开始。
补充说明一点,IO重用并不局限于用在socket编程上,只要涉及到IO的编程都可以应用。
select的用法
select技术主要由一个函数和几个宏来提供支持,下面是它们的说明:
/*
* 功能:监视多个文件描述符,直到有一个或多个文件描述符准备好做某种IO操作时返回
* 返回:当调用成功时,返回已准备好的文件描述符个数;发生错误时返回-1,可以通过errno得到错误类型
* 参数:
* nfds - 后面三个文件描述符集合中最大的文件描述符加1(想想为什么?)
* readfds - 等待进行读操作的文件描述符,指针传递,在select返回时会改变这个参数的值,只保留已准备好的文件描述符
* writefds - 等待进行写操作的文件描述符,指针传递,在select返回时会改变这个参数的值,只保留已准备好的文件描述符
* exceptfds - 监视异常的文件描述符号,很少用到
* timeout - 程序会在select调用的地方阻塞,你可以通过设置超时让程序可以在一定时间间隔后继续执行
*/
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
/*
* 功能:将指定文件描述符从指定的描述符集合中清除
* 参数:
* fd - 文件描述符
* set - 文件描述符集合
*/
void FD_CLR(int fd, fd_set *set);
/*
* 功能:检查一个文件描述符是否在集合中
* 返回:存在时返回非0整数,不存在则返回0
* 参数:
* fd - 文件描述符
* set - 文件描述符集合
*/
int FD_ISSET(int fd, fd_set *set);
/*
* 功能:设置一个文件描述符到集合中
* 参数:
* fd - 文件描述符
* set - 文件描述符集合
*/
void FD_SET(int fd, fd_set *set);
/*
* 功能:将一个文件描述符集合清零
* 参数:
* set - 文件描述符集合
*/
void FD_ZERO(fd_set *set);
在socket编程中,典型的select用法是:在创建监听的服务器socket文件描述符后,使用FD_ZERO初始化一个空的文件描述符集合,然后把监听的socket文件描述符通过FD_SET放入"读"集合中,然后进入服务器循环,当select返回时,用FD_ISSET检查readfds中是否有监听的socket文件描述符时,如果有说明有新的连接请求,这时候就调用accept接受连接,把accept返回的连接文件描述符放入自己维护的一个连接集合中,并把连接的文件描述符通过FD_SET放入"读"集合中,通过循环自己维护的连接集合中的文件描述符,调用FD_ISSET来判断是否某个连接有数据可读取,如果有就读取并处理。
上面这么说可能很笼统也很模糊,下面通过具体的代码演示select的用法和连接的维护让大家对select有一个具象的了解:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define SD_PORT 10086
#define SD_BACK_LOG 10
#define SD_MAX_CLIENT 3
int sd_listener_fd;
void
sd_init ()
{
int reuse = 1;
struct sockaddr_in addr;
if ((sd_listener_fd = socket(PF_INET, SOCK_STREAM, 0)) == -1)
{
perror("Create listener socket failed");
exit(-1);
}
if (setsockopt(sd_listener_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1)
{
perror("Setup listener socket failed");
exit(-1);
}
bzero(&(addr.sin_zero), 8);
addr.sin_family = AF_INET;
addr.sin_port = htons(SD_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(sd_listener_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1)
{
perror("Bind listener socket address failed");
exit(-1);
}
if (listen(sd_listener_fd, SD_BACK_LOG) == -1)
{
perror("Listen port failed");
exit(-1);
}
}
void
sd_loop ()
{
char buf[1024];
int i = 0, j = 0;
int ret = 0;
int client_fd;
int client_addr_len;
int client_fds[SD_MAX_CLIENT];
int client_fd_max_i = -1;
int max_fd = sd_listener_fd;
struct sockaddr_in client_addr;
fd_set read_fds, ready_read_fds;
FD_ZERO(&read_fds);
FD_SET(sd_listener_fd, &read_fds);
for (i = 0; i < SD_MAX_CLIENT; i++)
{
client_fds[i] = -1;
}
printf("Waiting connect on port %dn", SD_PORT);
for (;;)
{
ready_read_fds = read_fds;
ret = select(max_fd + 1, &ready_read_fds, NULL, NULL, NULL);
if (ret == -1)
{
perror("Select failed");
break;
}
if (ret == 0)
continue;
if (FD_ISSET(sd_listener_fd, &ready_read_fds))
{
client_fd = accept(sd_listener_fd, (struct sockaddr *)&client_addr, &client_addr_len);
FD_SET(client_fd, &read_fds);
for (i = 0; i < SD_MAX_CLIENT; i++)
{
if (client_fds[i] == -1)
{
client_fds[i] = client_fd;
break;
}
}
printf("Client connectedn");
if (i == SD_MAX_CLIENT - 1)
{
FD_CLR(sd_listener_fd, &read_fds);
printf("Connection is fulln");
}
if (client_fd > max_fd)
max_fd = client_fd;
if (i > client_fd_max_i)
client_fd_max_i = i;
if (-- ret == 0)
continue;
}
for (i = 0; i <= client_fd_max_i; i ++)
{
client_fd = client_fds[i];
if (client_fd == -1)
continue;
if (FD_ISSET(client_fd, &ready_read_fds))
{
if ((ret = read(client_fd, buf, 1024)) == 0)
{
close(client_fd);
FD_CLR(client_fd, &read_fds);
client_fds[i] = -1;
printf("Client closedn");
if (!FD_ISSET(sd_listener_fd, &read_fds))
{
FD_SET(sd_listener_fd, &read_fds);
printf("Listener come backn");
}
max_fd = sd_listener_fd;
for (j = 0; j <= client_fd_max_i; j ++)
{
if (client_fds[j] > max_fd)
max_fd = client_fds[j];
}
if (i == client_fd_max_i)
{
while ((client_fd_max_i -= 1) < -1)
{
if (client_fds[client_fd_max_i] != -1)
break;
}
}
}
else
{
write(client_fd, buf, ret);
}
if (-- ret == 0)
break;
}
}
}
}
void
sd_down ()
{
close(sd_listener_fd);
printf("Server shutdownn");
}
int
main (int argc, char *argv[])
{
sd_init();
sd_loop();
sd_down();
return 1;
}
上面的代码比前一章的例子复杂了很多,因为现在我们的echo服务器已经具备了同时响应多个客户端的能力。编译方式还是跟前一章一样,这里就不再重复说明。你现在可以同时用多个telnet连接到服务器上进行测试,为了方便测试,我通过SD_MAX_CLIENT指定了最大连接数是3,当达到最大连接数时,服务器就不再接受新的连接了。
比起之前的例子,代码增加都在sd_loop中,看起来很长,但如果分解开来其实逻辑很清晰。前面一大块是变量声明,接着进入无限循环,循环周期的开头阻塞在select调用,直到有文件描述准备好做操作select才返回,然后就是一个if包围的新连接接入处理,接着是for循环包围的连接请求处理。
因为select返回时会改变参数的值,所以在每次select之前,我们都会把read_fds赋值给ready_read_fds,然后把ready_read_fds传递给select函数,这样read_fds本身就不会受到影响了。
示例中没有的东西
上面的示例只有一个模式,而select函数实际上有三种使用模式,具体内容大家可以通过man select查阅文档。
示例中代码只体现了select的用法,并没有直接告诉我们select背后的机制和原理,不过我们通过使用方式来自己推测个大概。当然,我们也可以通过阅读Linux内核代码做到100%了解,但这就离我们主题有些远了,留给大家自己研究吧。我们把监听的文件描诉符集合丢给select,自然select内部会遍历监视这些文件描诉符,然后把具备特定状态的文件描述符保留在监视集合中其余的清除,然后把集合返回给调用者。所以select内部应该是一个遍历过程,而遍历过程需要有遍历结束的判断,所以才会需要我们传入maxfd作为参数。
示例中不能体现出的还有一点就是select的局限性,select能较好的解决io重用的问题,至少大幅度的提高了我们程序中io的使用效率(从无到有),但是它并不是完美的,它也有一些需要改进的地方。select在有些操作系统上有单个进程监听的文件描述符个数限制,至少通过万能的网络,我可以确认在Linux和Windows内核中的确有这样的限制,Linux内核代码通过一个FD_SETSIZE的宏约束了文件描诉符集合的元素上限,这个值默认是1024,也就是说我们示例程序监视的客户端连接最多只能有1024个,网上也有人提供各种奇技淫巧来突破这个限制,不过我不推荐这么做,因为这样做不自然,并且还有其他的io重用技术可以让我们选用。
我们的示例代码业务逻辑很简单,它只是简单返回收到的内容,但假设我们现在做的是一个MMORPG游戏的服务器,它在收到请求是可能需要执行复杂的游戏操作逻辑,那么socket服务器是不是很可能在业务逻辑处理的地方阻塞了呢?就像本章开头的银行里子中,我们假设了营业员充分高效,但现实生活中并不一定是这样。这就是有待我们解决的问题了。
示例中只用到select的readfds参数。原因很简单,为了让代码简单,如果用上writefds意味着就要区分开客户端的读和写处理,这会让代码更加复杂,对于我们演示select的基本使用来说这是不利的。但是对于高性能socket服务器来说区分读和写是必需的,因为你得用尽一切办法避免阻塞,上面的例子在实际应用中,很可能会在write的地方由于客户端没有准备好或者网络不畅导致程序在此位置阻塞,这就像是银行那个例子中有人没填好表格就跑到柜台前占着位置填表格,其实没有在做正事但后面的人都得等他一样,会影响到整体的执行效率。
下面是经过进一步抽象和细化的echo服务器,它已经将读写事件区分开,并把服务器状态和连接抽象成不同的数据结构,由不同的函数负责不同操作:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define SD_PORT 10086
#define SD_BACK_LOG 10
#define SD_MAX_CONN 10
#define sd_MAX_CONN 2000
typedef struct
{
int buf_len;
char* buf;
}
sd_conn_state;
typedef struct
{
int id;
int fd;
struct sockaddr_in addr;
sd_conn_state* state;
}
sd_conn;
typedef struct
{
int max_fd;
int listener_fd;
fd_set read_fds;
fd_set can_read_fds;
fd_set write_fds;
fd_set can_write_fds;
int conn_max;
int conn_free;
int conn_count;
sd_conn* conn_items;
}
sd_state;
void
sd_init (sd_state* state)
{
int i;
sd_conn* conn;
int reuse = 1;
struct sockaddr_in addr;
if ((state->listener_fd = socket(PF_INET, SOCK_STREAM, 0)) == -1)
{
perror("Create listener socket failed");
exit(1);
}
if (setsockopt(state->listener_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1)
{
perror("Setup listener socket failed");
exit(1);
}
bzero(&(addr.sin_zero), 8);
addr.sin_family = AF_INET;
addr.sin_port = htons(SD_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(state->listener_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1)
{
perror("Bind listener socket address failed");
exit(1);
}
if (listen(state->listener_fd, SD_BACK_LOG) == -1)
{
perror("Listen port failed");
exit(1);
}
state->max_fd = state->listener_fd;
FD_ZERO(&state->read_fds);
FD_ZERO(&state->can_read_fds);
FD_ZERO(&state->write_fds);
FD_ZERO(&state->can_write_fds);
FD_SET(state->listener_fd, &state->read_fds);
state->conn_max = -1;
state->conn_free = SD_MAX_CONN;
state->conn_count = SD_MAX_CONN;
state->conn_items = calloc(SD_MAX_CONN, sizeof(sd_conn));
for (i = 0; i < SD_MAX_CONN; i++)
{
conn = &state->conn_items[i];
conn->id = -1;
conn->fd = -1;
conn->state = NULL;
}
}
void
sd_conn_accept (sd_state* state)
{
int i;
int addr_size;
sd_conn* conn;
for (i = 0; i < state->conn_count; i ++)
{
conn = &state->conn_items[i];
if (conn->fd >= 0)
continue;
conn->id = i;
conn->state = NULL;
addr_size = sizeof(conn->addr);
conn->fd = accept(state->listener_fd, (struct sockaddr *)&conn->addr, &addr_size);
if (conn->fd > state->max_fd)
state->max_fd = conn->fd;
if (conn->id > state->conn_max)
state->conn_max = conn->id;
FD_SET(conn->fd, &state->read_fds);
state->conn_free --;
printf("Client connectedn");
break;
}
}
void
sd_conn_close (sd_state* state, sd_conn* conn)
{
int i;
sd_conn* temp_conn;
if (conn->id == state->conn_max)
{
for (i = conn->id - 1; i >= 0; i --)
{
if (i < 0)
{
state->conn_max = -1;
break;
}
temp_conn = &state->conn_items[i];
if (temp_conn->fd > 0)
{
state->conn_max = temp_conn->id;
break;
}
}
}
if (conn->fd == state->max_fd)
{
if (state->conn_max >= 0)
{
state->max_fd = state->listener_fd;
for (i = 0; i <= state->conn_max; i ++)
{
temp_conn = &state->conn_items[i];
if (temp_conn->fd > state->max_fd)
{
state->max_fd = temp_conn->fd;
}
}
}
}
FD_CLR(conn->fd, &state->read_fds);
FD_CLR(conn->fd, &state->write_fds);
close(conn->fd);
conn->id = -1;
conn->fd = -1;
if (conn->state != NULL)
{
free(conn->state);
}
state->conn_free ++;
printf("Client closen");
}
void
sd_conn_proc (sd_state* state, sd_conn* conn)
{
int ret;
char* buf;
buf = calloc(1024, sizeof(char));
if ((ret = read(conn->fd, buf, 1024)) == 0)
{
sd_conn_close(state, conn);
free(buf);
}
else
{
conn->state = calloc(1, sizeof(sd_conn_state));
conn->state->buf = buf;
conn->state->buf_len = ret;
FD_SET(conn->fd, &state->write_fds);
}
}
void
sd_conn_repo (sd_state* state, sd_conn* conn)
{
if (conn->state == NULL)
return;
write(conn->fd, conn->state->buf, conn->state->buf_len);
free(conn->state->buf);
free(conn->state);
conn->state = NULL;
FD_CLR(conn->fd, &state->write_fds);
}
void
sd_loop (sd_state* state)
{
int i = 0;
sd_conn* conn;
for (;;)
{
state->can_read_fds = state->read_fds;
state->can_write_fds = state->write_fds;
int num_ready = select(state->max_fd + 1, &state->can_read_fds, &state->can_write_fds, NULL, NULL);
if (num_ready == 0)
continue;
if (num_ready == -1)
{
perror("Select failedn");
exit(1);
}
if (FD_ISSET(state->listener_fd, &state->can_read_fds))
{
sd_conn_accept(state);
if (-- num_ready == 0)
continue;
}
for (i = 0; i <= state->conn_max; i ++)
{
conn = &state->conn_items[i];
if (conn->fd == -1)
continue;
if (FD_ISSET(conn->fd, &state->can_read_fds))
{
sd_conn_proc(state, conn);
if (-- num_ready == 0)
break;
}
if (FD_ISSET(conn->fd, &state->can_write_fds))
{
sd_conn_repo(state, conn);
if (-- num_ready == 0)
break;
}
}
}
}
void
sd_down (sd_state* state)
{
close(state->listener_fd);
printf("Server shutdownn");
}
int
main (int argc, char *argv[])
{
sd_state state;
sd_init(&state);
sd_loop(&state);
sd_down(&state);
return 1;
}
本章总结
本章通过演示select的使用向大家展示了io重用技术是怎样提高io使用效率的,让我们的socket服务器程序的可用向前迈了一大步。
但是迈出这步后,还有无数的挑战的等着我们,例如上面说的select的限制、复杂业务逻辑阻塞,等等。
后续我可能会继续花一到两章来描述poll和epoll,但也可能直接就介绍如何使用夸平台的libev和libevent库。特别是poll实际上它对select来说没有改进的地方,属于同一水平,实际上它们只是起源不一样,但是时代差不多所以水平也就差不多,而后续的epoll、dev/poll、iocp则是百家争鸣时期各个操作系统平台为了进一步提高io重用效率而设计的新机制,它们才本质上对select和poll等老模式进行了改进,所以我可能会跳过poll介绍epoll。
libev的实验代码我其实已经做好了,poll和epoll有点懒得重复了,具体怎么样还不确定就留个悬念吧,呵呵。