用PHP构建高性能的TCP/UDP服务器
如果web server直连db,那么当web server被攻破以后,黑客可以在代码中找到db的用户名和密码,可能会造成被拖库的危险。并且对于db来说,其连接数是有上限的,当多个cgi都需要连接db的时候很有可能会因为db连接数达到上限而拒绝服务。因此在webserver和db之间增加一个中间层变得很必要,中间层和db是保持长连接的。当有数据请求时,web server和中间层server用私有协议(非SQL)来交互,从而提高安全性和性能。这就是中间层server的雏形。
随着web业务的不断多样化,中间层server的作用已经远远不止转发db数据这么简单,它已经提供完整的TCP和UDP服务。下面就介绍一下架构。
1. TCP Server
与大多数server的架构类似,整个TCP server由master进程,Listener进程和worker进程组成。master进程负责监听信号和listener/worker进程的健康状况,在进程意外终止时将其重新拉起。Listener进程负责hold住客户端的连接,而worker进程来做真正的业务逻辑。由于listener只是简单地负责路由和转包,不涉及任何阻塞的调用,所以总是不会被阻塞。listener和worker之间选用unix域socket作为通信机制,由于通信被限定在亲戚进程之间,所以我们选取了无名unix域socket的一种实现--socketpair来完成这件事情。
一般来说listener的数量要小于worker的数量,为了便于绘图和描述,在下面这个例子当中,我们假定listener数量为2,worker数量为5。
1.1 Master进程
当服务启动时,首先由当前进程(master进程)
- 创建worker数量(5个)的socketpair,放在静态变量中(用于发包给worker)
- 创建listener数量(2个)的socketpair,放在静态变量中(用于从worker收包)
- 创建一个网络socket,bind,listen用于和用户间的通信
接下来就是fork()啦,同时有些细节问题需要处理
- 改变子进程的身份
- 打开子进程的CPU亲和选项
由于所有的socket是放在静态变量里面的,即master进程的数据段,因此fork()以后,在子进程中依然可以访问这些socket。也正是因为这些socket在多个进程之间两两配对,使得listener和worker可以实现通信。
unix域socket是用于同一台机器上运行时进程间的通信,虽然它和INET域socket被封装成了同样的接口,但是内部实现完全不一样。其仅仅复制数据,并不执行协议处理,不需要添加和删除网络报头,计算检验和,产生顺序号,发送确认报文等等因此其效率更高。unix域socket提供了TCP和UDP两种接口,我们应该选用哪一种呢?当然是UDP了,作为无连接状态的协议,不需要保留连接态,这样可以做到纯异步。但问题是UDP协议会不会导致丢包呢?是不是不保证顺序呢?答案是不会,原因很明显,unix域socket是基于管道实现的,因此是可靠的,既不会丢失消息,也不会传递出错。
现在进程结构变成了这样
master进程到此就完成了初始化的工作,它接下来就进入了监听信号和处理信号的主循环当中。其主要作用就是监控所有子进程的健康状态,做出相应地处理,并且接收系统信号便于处理管理员的reload,restart,stop和上报运行状态等需求,同时,master可以动态的配置子进程的个数(TODO)。
如果发现子进程状态发生变化(SIGCHLD),则将其重新拉起,如果是系统的退出信号,则设置标志位,待其他信号都处理好以后再平滑的退出。
1.2 Listener和Worker进程
由于所有的listener和worker都是master进程的子进程,拥有master创建的1+5+2的sock,所以当lisener和worker进程启动以后,做的第一件事情就是要把自己需要关心的sock告诉内核,即放在epoll中。
我们会发现这张图和前面刚刚fock出来的图不太一样,感觉少了些东西。原因是,这张图里面的sock是放在epoll中的sock,对于每个listener和worker来说,其关心的sock并不相同。例如,n号worker只需要将socketpair_n放入epoll中(下图中同一颜色的pair),用于接收listener发过来的数据。
将所有的sock放在epoll中以后,我们只需要在主loop中调用epoll_wait得到需要处理的事件就可以了,从而实现了纯异步。
此时所有的lisener进程都在监听统一端口,当用户发起连接请求时,只有一个lisener可以accept成功。
一旦accept成功以后,需要将新的sock(下图中的红色方块)放在epoll中,用以接收用户的数据。
当有用户数据到达时,Listener通过round rolling的方法选定一个socketpair,转包给一个特定的worker。
epoll提供了两种事件触发的机制,一种是ET(边缘触发),一种是LT(高电平触发)。两者的区别是,对于ET模式,当缓冲区里第一次出现数据,内核会通知我们socket可读事件,如果此时没有及时的将数据读完,后续内核将不再继续通知。而LT模式中,只要缓冲区中有数据,就会触发socket可读事件。从内核层面来说,ET模式的效率更高,因为系统只需要做一次通知,NGINX就是使用了epoll的这种模式,因此每次有可读事件触发时,nginx的worker需要一次性的将缓冲区中的数据读完。但是在实现中,由于我们的epoll使用的是LT模式,一方面原因是编码比较方便,还有一个重要的原因是libevent的php扩展只支持LT模式。所以每次在内核通知我们sock可读事件发生时,listener读取8k的数据再做转发即可。
由于TCP包是无边界的,而中间我们却用了UDP协议来转包,那么就涉及到了几个问题:
- 同一个client发过来的包要被转到同一个worker去处理,这样worker才能拼出正确的有意义的请求
- worker收到包以后可以将其按照不同的client来分类,因为有可能多个client的包发给了同一个worker
- 包处理好以后的回包要找到正确的listener
- listener拿到回包以后能定位到正确的回包socket(红色的方块),即正确的连接
我们的解决方法是在原始数据的基础上为每个包加一个包头,包头的内容就是一个用户的标签(具体实现中用ip+port标记),图中用颜色(黄,紫,橙)来标记。同时listener需要维护2个连接池,第一个是用标签定位client-listener间的sock,第二个是用标签定位listener-worker间的sock。同时,listener在发包给worker时还需要表明自己的身份,方便worker选择正确的sock回包,因此listener向worker发包时,包头包括用户的ip,port和listener的唯一ID(编号)。
这样一个简单地异步tcp server就构建好了。在业务使用的时候,只需要去实现worker的process方法就可以轻松搞定了。
2. UDP Server
由于UDP是无连接状态的,并且单独的每个包都是有意义的,所以设计起来就很容易啦。我们选用msgqueue作为listener和worker通信媒介。这个msgqueue就是named的了,所有的listener和worker可以根据msgqueue的唯一key对其进行使用。
同理,创建一个网络socket,用于接受client发过来的包,得到消息队列的描述符,用于listener和worker之间的通信。在fork出一个listener和若干个worker后,master进程就进入了监听消息的主循环当中了。
Lisener启动时,将用户连接的sock放在epoll中。当有用户的请求时,内核会通知其状态的变化。与TCP Server不一样的是,这一次epoll中只有listener的sock。由于IPC消息队列是纯内存维护,在通用文件系统中并没有对应的映射,所以不支持epoll,因此msg_queue数据的读取是由worker在闲时主动去read的。
每个worker就进入了读msg_queue -> 处理数据的循环当中。
worker在处理好数据以后,重用了master的sock,根据每个包打上的标记(ip,port)直接回包给client.
所以当系统拥塞的时候,首先溢出的是消息队列。
3. 一些细节
如果你只是看一下结构的话,到这里就可以了,后面就是一些实现上的细节了
3.1 改变进程的身份
master进程涉及到很多privileged系统调用,所以是以root身份来运行。我们知道,在fork()后,worker和listener继承了父进程的身份,即具有root的权限,这显然是不符合最小特权(least privilege)的原则(即我们的程序应该只具有为完成给定任务所需的最小特权,这减小了安全性受到损害的可能性)。因此在fork以后需要改变子进程的身份。
当查看系统的API我们会发现,有setuid()和seteuid()两种方法,到底应该使用哪一个呢?
我们知道当一个进程试图access一个文件的时候,内核会根据进程的身份和文件的权限位来判断是否可以做相应的操作。而对于一个进程来说,内核为其维护了三个身份,分别为:
- 真实身份: real UID, real GID
- 有效身份: effective UID, effective GID
- 存储身份:saved UID, saved GID
其中,用来校验权限的其实是有效用户身份。所以直观上来说,master进程需要调用seteuid()来改变子进程的有效身份。但这样并不能解决问题,因为内核之所以为进程维护三套身份的原因是进程在运行的过程当中可能会需要用到其他用户的权限,因此多套身份的设定就是为了帮助进程在运行时临时提权。
在进程运行的过程当中,进程可以选择将真实身份或者存储身份复制到有效身份,以拥有真实身份或者存储身份的权限。因此仅仅设定listener和worker的有效身份还是使得其有可能获取root权限。
所以答案就比较明显了,这里需要将子进程的三个身份均改变成nobody。
我们再来看看setuid()
- 若进程拥有root权限,setuid函数将real UID, effective UID和saved UID设置为uid。
- 若进程没有root权限,但是uid等于real UID 或者saved UID,则setuid只将effective UID设置为uid,不改变另外两个
- 若以上两个条件都不满足,则直接返回错误。
说了这么多,我们发现当前刚好属于第一种情况,因此在master进程当中,将所有的子进程做setuid()和setgid()操作。
我们看到一切已经符合预期了。那么问题就来了,ps aux显示的第一列USER到底显示的是进程的什么身份?
3.2 master和listener/worker之间的通信机制
从上文可知,TCP server中listener和worker之间的通信是通过unix socket来实现的,而UDP server中则由消息队列来完成。但是始终没有提到master和其子进程(listener & worker)之间的通信机制。
首先看一下通信的需求
(1) 当管理员需要stop,reload,restart时,需要由master来通知子进程
(2) listener和worker状态发生变化时(例如意外退出),需要通知到master进程
对于第一点来说,master只需要很小的包就可以通知到子进程这些操作,这个包可以小到只包含一个整数就可以了,因此我们自然想到了信号。用内核提供给我们的USER1和USER2信号就可以解决这个问题啦。这里做一个对比,由于NGINX中间涉及到得状态比较多,因此其使用socketpair来完成第一点需求。
对于第二点来说,由于listener和worker均为master的子进程,所以内核已经帮我们完成了这件事情,当子进程状态发生变化时,内核会将SIGCHLD信号发给父进程。因此解决方法无非两种:一种是注册SIGCHLD的handler函数,一种就是在master进程中wait或者waitpid来捕获事件。
3.3 CPU亲和
在多处理器的机器上面,一般内核对cpu的调度都是当cpu0的负载即将达到上限的时候启用cpu1,这样顺序执行下去。进程间切换的内存复制造成了对资源的浪费。因此对于一个高效的服务,我们希望所有的worker,listener进程都可以concurrent的执行,而非所有的进程都在内核的调度下使用同一个cpu来运行。
好在linux通过 sched_setaffinity 将内核对cpu的调度部分的暴露了出来,使得可以将一个进程绑定在一组cpu上面。
因此在实现中,我们将listener和worker用index对cpu数取余的方式绑定在了一个特定的cpu上面用以提高性能。
4. 需要改进的问题
solar server一直在成长的过程当中,我们也一直在学习优秀的架构,在solar服役的过程当中,发现其实其有很多方面需要去发展和改进。
4.1 负载均衡
我们看到在listener选择worker的方法是通过简单的RR算法实现的,即从0号worker开始轮下去。
也就是说请求包是均匀的发送到各个worker上面的,如果worker处理所有的请求的时间均一致,那么这种算法是没有什么问题的。但是,worker处理请求的时长是不可控的,所以这样的结构很有可能造成有的worker间的负载完全不均衡。
由于worker的process方法是由业务方来实现的,因此需要一个listener和worker之间的通信机制,将worker的繁忙程度回包给listener,以便listener可以选择当前负载较低的worker。
我们来看一下NGINX是如何解决负载均衡的的问题的。nginx的结构比较简单,只有master和worker进程,master进程和Solar的master功能类似,都是只负责接收信号和负责worker进程的健康状态。所有的连接和干活都是由worker进程来handle的。
其实NGINX解决负载均衡的方法很粗暴,就是看当前worker的连接数(统计连接池的已用连接数),是否为最大连接数(配置)的7/8以上,如果大于这个阈值,则不可以接收新的连接。
4.2 惊群问题
什么是惊群问题?就是当你扔了一块面包在广场上,所有的鸽子都会过来抢,但是最终只有一只能够抢成功,对于没有抢成功的鸽子来说白白浪费了体力。
对于linux服务器来说,就是多个进程同时监听(listen)一个端口,当有新连接请求发送到这个端口时,内核会通知所有打开这个端口的进程,但是最终只有一个进程可以accept成功,这就造成了系统资源的浪费。
问题是,如何做到多个进程监听同一个端口呢?难道不会bind失败么?
- 先fork再bind
- 先bind,listen再fork
如果是两个独立的进程试图去bind同一个端口,这两个进程中的socket在文件系统中是两个独立的文件,而如果他们试图和同一个网卡去绑定的时候,必定会产生冲突。所以bind时会直接返回错误。但如果是先bind, listen再去fork的话,对于每个进程的sock在文件系统中只有一份镜像,所以就不会产生冲突,但是会发生前面所提到的惊群问题。(感谢gexiaobaoHelloWorld的图)
一个比较经典的解决惊群问题的方法就是锁了,只有拿到锁的进程才可以去listen,这样就保证同时只有一个进程在listen,也只有这个进程可以accept。
NGINX就是利用锁来保证只有一个进程在listen socket的。NGINX使用的是自旋锁机制,原因是,每一个worker都是绑定在一个CPU上的,为了更好地exploit系统的性能,应尽量的使得每个worker进程不处于阻塞态。NGINX根据不同的CPU architecture实现了各自的自旋锁,保证当worker无法获得锁时处于ready状态而不会进入阻塞态,从而减少无谓的上下文切换。但是使用自旋锁的一个基本规范是进程占用锁的时间一定要短,否则wait锁的进程会占用大量的系统资源。因此如何尽快的释放锁就成了一个问题。NGINX的解决方案是,当进程获取锁成功以后,接下来并不是把所有epoll中的事件全部处理掉,而是将事件分成两种,一种是新连接事件,一种是普通事件,分别在内存中用两个链表对其进行维护。在一次循环中,worker首先将新连接事件处理掉,释放锁,然后再去处理普通事件。用这种机制就可以保证锁被及时的释放掉。
5. more
目前中间层server现在还在不断的发展当中,还有很多feature正在开发当中,例如对定时事件的支持,worker和后端server,db之间交互的进一步异步,以及超时保护等等。
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。