系列目录
第01篇 主线程与工作线程的分工
第02篇 Reactor模式
第03篇 一个服务器程序的架构介绍
第04篇 如何将socket设置为非阻塞模式
第05篇 如何编写高性能日志
第06篇 关于网络编程的一些实用技巧和细节
第07篇 开源一款即时通讯软件的源码
第08篇 高性能服务器架构设计总结1
第09篇 高性能服务器架构设计总结2
第10篇 高性能服务器架构设计总结3
第11篇 高性能服务器架构设计总结4
这篇文章算是对这个系列的一个系统性地总结。我们将介绍服务器的开发,并从多个方面探究如何开发一款高性能高并发的服务器程序。
所谓高性能就是服务器能流畅地处理各个客户端的连接并尽量低延迟地应答客户端的请求;所谓高并发,指的是服务器可以同时支持多的客户端连接,且这些客户端在连接期间内会不断与服务器有数据来往。
这篇文章将从两个方面来介绍,一个是服务器的框架,即单个服务器程序的代码组织结构;另外一个是一组服务程序的如何组织与交互,即架构。注意:本文以下内容中的客户端是相对概念,指的是连接到当前讨论的服务程序的终端,所以这里的客户端既可能是我们传统意义上的客户端程序,也可能是连接该服务的其他服务器程序。
按上面介绍的思路,我们先从单个服务程序的组织结构开始介绍。
既然是服务器程序肯定会涉及到网络通信部分,那么服务器程序的网络通信模块要解决哪些问题?
笔者认为至少要解决以下问题:
稍微有点网络基础的人,都能回答上面说的其中几个问题,比如接收客户端连接用socket API的accept函数,收取客户端数据用recv函数,给客户端发送数据用send函数,检测客户端是否有新连接和客户端是否有新数据可以用IO multiplexing技术(IO复用)的select、poll、epoll等socket API。确实是这样的,这些基础的socket API构成了服务器网络通信的地基,不管网络通信框架设计的如何巧妙,都是在这些基础的socket API的基础上构建的。但是如何巧妙地组织这些基础的socket API,才是问题的关键。我们说服务器很高效,支持高并发,实际上只是一个技术实现手段,不管怎样从软件开发的角度来讲无非就是一个程序而已,所以,只要程序能最大可能地满足“尽量减少等待”就是高效。也就是说高效不是“忙的忙死,闲的闲死”,而是大家都可以闲着,但是如果有活要干,大家尽量一起干,而不是一部分忙着依次做事情123456789,另外一部分闲在那里无所事事。说的可能有点抽象,下面我们来举一些例子具体来说明一下。
比如默认recv函数如果没有数据的时候,线程就会阻塞在那里; 默认send函数,如果tcp窗口不是足够大,数据发不出去也会阻塞在那里; connect函数默认连接另外一端的时候,也会阻塞在那里; 又或者是给对端发送一份数据,需要等待对端回答,如果对方一直不应答,当前线程就阻塞在这里。 以上都不是高效服务器的开发思维方式,因为上面的例子都不满足“尽量减少等待”的原则,为什么一定要等待呢?有没用一种方法,这些过程不需要等待,最好是不仅不需要等待,而且这些事情完成之后能通知我。这样在这些本来用于等待的cpu时间片内,我就可以做一些其他的事情。有,也就是我们下文要讨论的IO Multiplexing技术(IO复用技术)。
目前windows系统支持select、WSAAsyncSelect、WSAEventSelect、完成端口(IOCP),linux系统支持select、poll、epoll。这里我们不具体介绍每个具体的函数的用法,我们来讨论一点深层次的东西,以上列举的API函数可以分为两个层次:
为什么这么分呢?先来介绍第一层次,select和poll函数本质上还是在一定时间内主动去查询socket句柄(可能是一个也可能是多个)上是否有事件,比如可读事件,可写事件或者出错事件,也就是说我们还是需要每隔一段时间内去主动去做这些检测,如果在这段时间内检测出一些事件来,我们这段时间就算没白花,但是倘若这段时间内没有事件呢?我们只能是做无用功了,说白了,还是在浪费时间,因为假如一个服务器有多个连接,在cpu时间片有限的情况下,我们花费了一定的时间检测了一部分socket连接,却发现它们什么事件都没有,而在这段时间内我们却有一些事情需要处理,那我们为什么要花时间去做这个检测呢?把这个时间用在做我们需要做的事情不好吗?所以对于服务器程序来说,要想高效,我们应该尽量避免花费时间主动去查询一些socket是否有事件,而是等这些socket有事件的时候告诉我们去处理。这也就是层次二的各个函数做的事情,它们实际相当于变主动查询是否有事件为当有事件时,系统会告诉我们,此时我们再去处理,也就是“好钢用在刀刃”上了。只不过层次二的函数通知我们的方式是各不相同,比如WSAAsyncSelect是利用windows消息队列的事件机制来通知我们设定的窗口过程函数,IOCP是利用GetQueuedCompletionStatus返回正确的状态,epoll是epoll_wait函数返回而已。
比如connect函数连接另外一端,如果连接socket是异步的,那么connect虽然不能立刻连接完成,但是也是会立刻返回,无需等待,等连接完成之后,WSAAsyncSelect会返回FD_CONNECT事件告诉我们连接成功,epoll会产生EPOLLOUT事件,我们也能知道连接完成。甚至socket有数据可读时,WSAAsyncSelect产生FD_READ事件,epoll产生EPOLLIN事件,等等。
所以有了上面的讨论,我们就可以得到网络通信检测可读可写或者出错事件的正确姿势。这是我这里提出的第二个原则:尽量减少做无用功的时间。这个在服务程序资源够用的情况下可能体现不出来什么优势,但是如果有大量的任务要处理,个人觉得这个可能带来无用。
根据上面的介绍,第一,为了避免无意义的等待时间,第二,不采用主动查询各个socket的事件,而是采用等待操作系统通知我们有事件的状态的策略。我们的socket都要设置成异步的。在此基础上我们回到栏目(一)中提到的七个问题:
在实际的应用中,被动关闭连接是由于我们检测到了连接的异常事件,比如EPOLLERR,或者对端关闭连接,send或recv返回0,这个时候这路连接已经没有存在必要的意义了,我们被迫关闭连接。
而主动关闭连接,是我们主动调用close/closesocket来关闭连接。比如客户端给我们发送非法的数据,比如一些网络攻击的尝试性数据包。这个时候出于安全考虑,我们关闭socket连接。
上面已经介绍了发送缓冲区了,并说明了其存在的意义。接收缓冲区也是一样的道理,当收到数据以后,我们可以直接进行解包,但是这样并不好,
说了这么多,那发送缓冲区和接收缓冲区该设计成多大的容量?这是一个老生常谈的问题了,因为我们经常遇到这样的问题:预分配的内存太小不够用,太大的话可能会造成浪费。怎么办呢?答案就是像string、vector一样,设计出一个可以动态增长的缓冲区,按需分配,不够还可以扩展。
需要特别注意的是,这里说的发送缓冲区和接收缓冲区是每一个socket连接都存在一个。这是我们最常见的设计方案。
除了一些通用的协议,如http、ftp协议以外,大多数服务器协议都是根据业务制定的。协议设计好了,数据包的格式就根据协议来设置。我们知道tcp/ip协议是流式数据,所以流式数据就是像流水一样,数据包与数据包之间没有明显的界限。比如A端给B端连续发了三个数据包,每个数据包都是50个字节,B端可能先收到10个字节,再收到140个字节;或者先收到20个字节,再收到20个字节,再收到110个字节;也可能一次性收到150个字节。这150个字节可以以任何字节数目组合和次数被B收到。
所以我们讨论协议的设计第一个问题就是如何界定包的界线,也就是接收端如何知道每个包数据的大小。目前常用有如下三种方法:
协议要讨论的第二个问题是,设计协议的时候要尽量方便解包,也就是说协议的格式字段应该尽量清晰明了。
协议要讨论的第三个问题是,根据协议组装的数据包应该尽量小,这样有如下好处:
协议要讨论的第四个问题是,对于数值类型,我们应该显式地指定数值的长度,比如long型,如果在32位机器上是32位的4个字节,但是如果在64位机器上,就变成了64位8个字节了。这样同样是一个long型,发送方和接收方可能会用不同的长度去解码。所以建议最好在涉及到跨平台使用的协议最好显式地指定协议中整型字段的长度,比如int32,int64等等。下面是一个协议的接口的例子:
1class BinaryReadStream
2{
3 private:
4 const char* const ptr;
5 const size_t len;
6 const char* cur;
7 BinaryReadStream(const BinaryReadStream&);
8 BinaryReadStream& operator=(const BinaryReadStream&);
9
10 public:
11 BinaryReadStream(const char* ptr, size_t len);
12 virtual const char* GetData() const;
13 virtual size_t GetSize() const;
14 bool IsEmpty() const;
15 bool ReadString(string* str,
16 size_t maxlen,
17 size_t& outlen);
18 bool ReadCString(char* str,
19 size_t strlen,
20 size_t& len);
21 bool ReadCCString(const char** str,
22 size_t maxlen,
23 size_t& outlen);
24 bool ReadInt32(int32_t& i);
25 bool ReadInt64(int64_t& i);
26 bool ReadShort(short& i);
27 bool ReadChar(char& c);
28 size_t ReadAll(char* szBuffer, size_t iLen) const;
29 bool IsEnd() const;
30 const char* GetCurrent() const{ return cur; }
31
32 public:
33 bool ReadLength(size_t & len);
34 bool ReadLengthWithoutOffset(size_t &headlen,
35 size_t & outlen);
36 };
37
38 class BinaryWriteStream
39 {
40 public:
41 BinaryWriteStream(string* data);
42 virtual const char* GetData() const;
43 virtual size_t GetSize() const;
44 bool WriteCString(const char* str, size_t len);
45 bool WriteString(const string& str);
46 bool WriteDouble(double value, bool isNULL = false);
47 bool WriteInt64(int64_t value, bool isNULL = false);
48 bool WriteInt32(int32_t i, bool isNULL = false);
49 bool WriteShort(short i, bool isNULL = false);
50 bool WriteChar(char c, bool isNULL = false);
51 size_t GetCurrentPos() const{ return m_data->length(); }
52 void Flush();
53 void Clear();
54 private:
55 string* m_data;
56 };
其中BinaryWriteStream是编码协议的类,BinaryReadStream是解码协议的类。可以按下面这种方式来编码和解码。
编码:
1std::string outbuf;
2BinaryWriteStream writeStream(&outbuf);
3writeStream.WriteInt32(msg_type_register);
4writeStream.WriteInt32(m_seq);
5writeStream.WriteString(retData);
6writeStream.Flush();
解码:
1BinaryReadStream readStream(strMsg.c_str(),
2 strMsg.length());
3int32_t cmd;
4if (!readStream.ReadInt32(cmd))
5{
6 return false;
7}
8
9//int seq;
10if (!readStream.ReadInt32(m_seq))
11{
12 return false;
13}
14
15std::string data;
16size_t datalength;
17if (!readStream.ReadString(&data, 0, datalength))
18{
19 return false;
20}
上面的六个标题,我们讨论了很多具体的细节问题,现在是时候讨论将这些细节组织起来了。根据我的个人经验,目前主流的思想是one thread one loop的策略。通俗点说就是在一个线程的函数里面不断地循环依次做一些事情,这些事情包括检测网络事件、解包数据产生业务逻辑。我们先从最简单地来说,设定一些线程在一个循环里面做网络通信相关的事情,伪码如下:
1while(退出标志)
2{
3 //IO复用技术检测socket可读事件、出错事件
4 //(如果有数据要发送,则也检测可写事件)
5
6 //如果有可读事件,对于侦听socket则接收新连接;
7 //对于普通socket则收取该socket上的数据,收取的数据存入对应的接收缓冲区,如果出错则关闭连接;
8
9 //如果有数据要发送,有可写事件,则发送数据
10
11 //如果有出错事件,关闭该连接
12}
` 另外设定一些线程去处理接收到的数据,并解包处理业务逻辑,这些线程可以认为是业务线程了,伪码如下:
1//从接收缓冲区中取出数据解包,分解成不同的业务来处理
上面的结构是目前最通用的服务器逻辑结构,但是能不能再简化一下或者说再综合一下呢?我们试试,你想过这样的问题没有:假如现在的机器有两个cpu,我们的网络线程数量是2个,业务逻辑线程也是2个,这样可能存在的情况就是:业务线程运行的时候,网络线程并没有运行,它们必须等待,如果是这样的话,干嘛要多建两个线程呢?除了程序结构上可能稍微清楚一点,对程序性能没有任何实质性提高,而且白白浪费cpu时间片在线程上下文切换上。所以,我们可以将网络线程与业务逻辑线程合并,合并后的伪码看起来是这样子的:
1while(退出标志)
2{
3 //IO复用技术检测socket可读事件、出错事件
4 //(如果有数据要发送,则也检测可写事件)
5
6 //如果有可读事件,对于侦听socket则接收新连接;
7 //对于普通socket则收取该socket上的数据,
8 //收取的数据存入对应的接收缓冲区,如果出错则关闭连接;
9
10 //如果有数据要发送,有可写事件,则发送数据
11
12 //如果有出错事件,关闭该连接
13
14 //从接收缓冲区中取出数据解包,分解成不同的业务来处理
15}
`你没看错,其实就是简单的合并,合并之后和不仅可以达到原来合并前的效果,而且在没有网络IO事件的时候,可以及时处理我们想处理的一些业务逻辑,并且减少了不必要的线程上下文切换时间。
我们再更进一步,甚至我们可以在这个while循环增加其它的一些任务的处理,比如程序的逻辑任务队列、定时器事件等等,伪码如下:
1while(退出标志)
2{
3 //定时器事件处理
4
5 //IO复用技术检测socket可读事件、出错事件
6 //(如果有数据要发送,则也检测可写事件)
7
8 //如果有可读事件,对于侦听socket则接收新连接;
9 //对于普通socket则收取该socket上的数据,
10 //收取的数据存入对应的接收缓冲区,如果出错则关闭连接;
11
12 //如果有数据要发送,有可写事件,则发送数据
13
14 //如果有出错事件,关闭该连接
15
16 //从接收缓冲区中取出数据解包,分解成不同的业务来处理
17
18 //程序自定义任务1
19
20 //程序自定义任务2
21}
注意:之所以将定时器事件的处理放在网络IO事件的检测之前,是因为避免定时器事件过期时间太长。假如放在后面的话,可能前面的处理耗费了一点时间,等到处理定时器事件时,时间间隔已经过去了不少时间。虽然这样处理,也没法保证定时器事件百分百精确,但是能尽量保证。
由于公众号文章字数有限,您可以接着阅读下一篇:《 《服务器端编程心得(八)——高性能服务器架构设计总结2——以flamigo服务器代码为例》》。
系列目录
第01篇 主线程与工作线程的分工
第02篇 Reactor模式
第03篇 一个服务器程序的架构介绍
第04篇 如何将socket设置为非阻塞模式
第05篇 如何编写高性能日志
第06篇 关于网络编程的一些实用技巧和细节
第07篇 开源一款即时通讯软件的源码
第08篇 高性能服务器架构设计总结1
第09篇 高性能服务器架构设计总结2
第10篇 高性能服务器架构设计总结3
第11篇 高性能服务器架构设计总结4