搜索
首页后端开发php教程用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之间交互的进一步异步,以及超时保护等等。

    声明
    本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
    超越炒作:评估当今PHP的角色超越炒作:评估当今PHP的角色Apr 12, 2025 am 12:17 AM

    PHP在现代编程中仍然是一个强大且广泛使用的工具,尤其在web开发领域。1)PHP易用且与数据库集成无缝,是许多开发者的首选。2)它支持动态内容生成和面向对象编程,适合快速创建和维护网站。3)PHP的性能可以通过缓存和优化数据库查询来提升,其广泛的社区和丰富生态系统使其在当今技术栈中仍具重要地位。

    PHP中的弱参考是什么?什么时候有用?PHP中的弱参考是什么?什么时候有用?Apr 12, 2025 am 12:13 AM

    在PHP中,弱引用是通过WeakReference类实现的,不会阻止垃圾回收器回收对象。弱引用适用于缓存系统和事件监听器等场景,需注意其不能保证对象存活,且垃圾回收可能延迟。

    解释PHP中的__ Invoke Magic方法。解释PHP中的__ Invoke Magic方法。Apr 12, 2025 am 12:07 AM

    \_\_invoke方法允许对象像函数一样被调用。1.定义\_\_invoke方法使对象可被调用。2.使用$obj(...)语法时,PHP会执行\_\_invoke方法。3.适用于日志记录和计算器等场景,提高代码灵活性和可读性。

    解释PHP 8.1中的纤维以进行并发。解释PHP 8.1中的纤维以进行并发。Apr 12, 2025 am 12:05 AM

    Fibers在PHP8.1中引入,提升了并发处理能力。1)Fibers是一种轻量级的并发模型,类似于协程。2)它们允许开发者手动控制任务的执行流,适合处理I/O密集型任务。3)使用Fibers可以编写更高效、响应性更强的代码。

    PHP社区:资源,支持和发展PHP社区:资源,支持和发展Apr 12, 2025 am 12:04 AM

    PHP社区提供了丰富的资源和支持,帮助开发者成长。1)资源包括官方文档、教程、博客和开源项目如Laravel和Symfony。2)支持可以通过StackOverflow、Reddit和Slack频道获得。3)开发动态可以通过关注RFC了解。4)融入社区可以通过积极参与、贡献代码和学习分享来实现。

    PHP与Python:了解差异PHP与Python:了解差异Apr 11, 2025 am 12:15 AM

    PHP和Python各有优势,选择应基于项目需求。1.PHP适合web开发,语法简单,执行效率高。2.Python适用于数据科学和机器学习,语法简洁,库丰富。

    php:死亡还是简单地适应?php:死亡还是简单地适应?Apr 11, 2025 am 12:13 AM

    PHP不是在消亡,而是在不断适应和进化。1)PHP从1994年起经历多次版本迭代,适应新技术趋势。2)目前广泛应用于电子商务、内容管理系统等领域。3)PHP8引入JIT编译器等功能,提升性能和现代化。4)使用OPcache和遵循PSR-12标准可优化性能和代码质量。

    PHP的未来:改编和创新PHP的未来:改编和创新Apr 11, 2025 am 12:01 AM

    PHP的未来将通过适应新技术趋势和引入创新特性来实现:1)适应云计算、容器化和微服务架构,支持Docker和Kubernetes;2)引入JIT编译器和枚举类型,提升性能和数据处理效率;3)持续优化性能和推广最佳实践。

    See all articles

    热AI工具

    Undresser.AI Undress

    Undresser.AI Undress

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

    AI Clothes Remover

    AI Clothes Remover

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

    Undress AI Tool

    Undress AI Tool

    免费脱衣服图片

    Clothoff.io

    Clothoff.io

    AI脱衣机

    AI Hentai Generator

    AI Hentai Generator

    免费生成ai无尽的。

    热门文章

    R.E.P.O.能量晶体解释及其做什么(黄色晶体)
    3 周前By尊渡假赌尊渡假赌尊渡假赌
    R.E.P.O.最佳图形设置
    3 周前By尊渡假赌尊渡假赌尊渡假赌
    R.E.P.O.如果您听不到任何人,如何修复音频
    3 周前By尊渡假赌尊渡假赌尊渡假赌
    WWE 2K25:如何解锁Myrise中的所有内容
    4 周前By尊渡假赌尊渡假赌尊渡假赌

    热工具

    Atom编辑器mac版下载

    Atom编辑器mac版下载

    最流行的的开源编辑器

    ZendStudio 13.5.1 Mac

    ZendStudio 13.5.1 Mac

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

    DVWA

    DVWA

    Damn Vulnerable Web App (DVWA) 是一个PHP/MySQL的Web应用程序,非常容易受到攻击。它的主要目标是成为安全专业人员在合法环境中测试自己的技能和工具的辅助工具,帮助Web开发人员更好地理解保护Web应用程序的过程,并帮助教师/学生在课堂环境中教授/学习Web应用程序安全。DVWA的目标是通过简单直接的界面练习一些最常见的Web漏洞,难度各不相同。请注意,该软件中

    WebStorm Mac版

    WebStorm Mac版

    好用的JavaScript开发工具

    安全考试浏览器

    安全考试浏览器

    Safe Exam Browser是一个安全的浏览器环境,用于安全地进行在线考试。该软件将任何计算机变成一个安全的工作站。它控制对任何实用工具的访问,并防止学生使用未经授权的资源。