目录
1. TCP Server
2. UDP Server
3. 一些细节
4. 需要改进的问题
5. more
首页 后端开发 php教程 用PHP构建高性能的TCP/UDP服务器

用PHP构建高性能的TCP/UDP服务器

Jun 23, 2016 pm 01:44 PM

如果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之间交互的进一步异步,以及超时保护等等。

    本站声明
    本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn

    热AI工具

    Undresser.AI Undress

    Undresser.AI Undress

    人工智能驱动的应用程序,用于创建逼真的裸体照片

    AI Clothes Remover

    AI Clothes Remover

    用于从照片中去除衣服的在线人工智能工具。

    Undress AI Tool

    Undress AI Tool

    免费脱衣服图片

    Clothoff.io

    Clothoff.io

    AI脱衣机

    Video Face Swap

    Video Face Swap

    使用我们完全免费的人工智能换脸工具轻松在任何视频中换脸!

    热工具

    记事本++7.3.1

    记事本++7.3.1

    好用且免费的代码编辑器

    SublimeText3汉化版

    SublimeText3汉化版

    中文版,非常好用

    禅工作室 13.0.1

    禅工作室 13.0.1

    功能强大的PHP集成开发环境

    Dreamweaver CS6

    Dreamweaver CS6

    视觉化网页开发工具

    SublimeText3 Mac版

    SublimeText3 Mac版

    神级代码编辑软件(SublimeText3)

    在PHP API中说明JSON Web令牌(JWT)及其用例。 在PHP API中说明JSON Web令牌(JWT)及其用例。 Apr 05, 2025 am 12:04 AM

    JWT是一种基于JSON的开放标准,用于在各方之间安全地传输信息,主要用于身份验证和信息交换。1.JWT由Header、Payload和Signature三部分组成。2.JWT的工作原理包括生成JWT、验证JWT和解析Payload三个步骤。3.在PHP中使用JWT进行身份验证时,可以生成和验证JWT,并在高级用法中包含用户角色和权限信息。4.常见错误包括签名验证失败、令牌过期和Payload过大,调试技巧包括使用调试工具和日志记录。5.性能优化和最佳实践包括使用合适的签名算法、合理设置有效期、

    PHP 8.1中的枚举(枚举)是什么? PHP 8.1中的枚举(枚举)是什么? Apr 03, 2025 am 12:05 AM

    PHP8.1中的枚举功能通过定义命名常量增强了代码的清晰度和类型安全性。1)枚举可以是整数、字符串或对象,提高了代码可读性和类型安全性。2)枚举基于类,支持面向对象特性,如遍历和反射。3)枚举可用于比较和赋值,确保类型安全。4)枚举支持添加方法,实现复杂逻辑。5)严格类型检查和错误处理可避免常见错误。6)枚举减少魔法值,提升可维护性,但需注意性能优化。

    会话如何劫持工作,如何在PHP中减轻它? 会话如何劫持工作,如何在PHP中减轻它? Apr 06, 2025 am 12:02 AM

    会话劫持可以通过以下步骤实现:1.获取会话ID,2.使用会话ID,3.保持会话活跃。在PHP中防范会话劫持的方法包括:1.使用session_regenerate_id()函数重新生成会话ID,2.通过数据库存储会话数据,3.确保所有会话数据通过HTTPS传输。

    描述扎实的原则及其如何应用于PHP的开发。 描述扎实的原则及其如何应用于PHP的开发。 Apr 03, 2025 am 12:04 AM

    SOLID原则在PHP开发中的应用包括:1.单一职责原则(SRP):每个类只负责一个功能。2.开闭原则(OCP):通过扩展而非修改实现变化。3.里氏替换原则(LSP):子类可替换基类而不影响程序正确性。4.接口隔离原则(ISP):使用细粒度接口避免依赖不使用的方法。5.依赖倒置原则(DIP):高低层次模块都依赖于抽象,通过依赖注入实现。

    解释PHP中的晚期静态绑定(静态::)。 解释PHP中的晚期静态绑定(静态::)。 Apr 03, 2025 am 12:04 AM

    静态绑定(static::)在PHP中实现晚期静态绑定(LSB),允许在静态上下文中引用调用类而非定义类。1)解析过程在运行时进行,2)在继承关系中向上查找调用类,3)可能带来性能开销。

    什么是REST API设计原理? 什么是REST API设计原理? Apr 04, 2025 am 12:01 AM

    RESTAPI设计原则包括资源定义、URI设计、HTTP方法使用、状态码使用、版本控制和HATEOAS。1.资源应使用名词表示并保持层次结构。2.HTTP方法应符合其语义,如GET用于获取资源。3.状态码应正确使用,如404表示资源不存在。4.版本控制可通过URI或头部实现。5.HATEOAS通过响应中的链接引导客户端操作。

    您如何在PHP中有效处理异常(尝试,捕捉,最后,投掷)? 您如何在PHP中有效处理异常(尝试,捕捉,最后,投掷)? Apr 05, 2025 am 12:03 AM

    在PHP中,异常处理通过try,catch,finally,和throw关键字实现。1)try块包围可能抛出异常的代码;2)catch块处理异常;3)finally块确保代码始终执行;4)throw用于手动抛出异常。这些机制帮助提升代码的健壮性和可维护性。

    PHP中的匿名类是什么?您何时可以使用它们? PHP中的匿名类是什么?您何时可以使用它们? Apr 04, 2025 am 12:02 AM

    匿名类在PHP中的主要作用是创建一次性使用的对象。1.匿名类允许在代码中直接定义没有名字的类,适用于临时需求。2.它们可以继承类或实现接口,增加灵活性。3.使用时需注意性能和代码可读性,避免重复定义相同的匿名类。

    See all articles