演示代码地址:https://git.cnphper.com/weicky/php_async_worker

课件1,通过使用 Redis 的 list 队列结构,和 Blocking IO 来实现了一个前后台(前台为 php-fpm-cgi 脚本,后台为 php-cli 脚本)业务分离的机制。

演示代码地址:https://git.cnphper.com/weicky/php_async_worker

课件2,通过使用UDP、SysvMSG作为中间层将 php-fpm-cgi 脚本发送的数据转发给 Redis 的 list 结构,再由 php-cli 脚本提取并处理,从而实现了 php-fpm-cgi 非阻塞式的发送数据(非阻塞发送的意义在于,异步处理系统本身故障时,不会因为发送异步任务消息而把 php-fpm-cgi 进程给阻塞住)。

前情回顾
     Redis所在服务器停机,IP不可达时TCP连接需要等待到超时才返回失败(真实事故,一次因为配置写错,一次因为服务器宕机)。
     问题原因:PHP的TCP连接操作是同步、阻塞的。
     思考1:是否可以改小TCP的连接超时设置?可以,但秒级超时依旧会导致问题出现,毫秒级超时又会导致在网络拥堵时会出现因为超时而导致的连接失败(真实案例)。
     思考2:TCP本身可以支持阻塞与非阻塞,这里是不是可以将PHP的TCP连接改为非阻塞?可以改,但改了之后怎么知道什么时候连接成功或失败呢?如果不等待连接成功就继续写数据,会导致数据发送失败,如果等待,非阻塞就变成了阻塞。
支持非阻塞的进程间通信方式
     信号:C的接口支持附带额外的数据(整数或指针),PHP的接口不支持,仅适合用于进程管理或通知。信号不排队,会合并,不可靠。还有一个小细节,只有相同用户或root用户的进程才有权限发信号。信号处理函数在fork之后会被子进程继承。
     共享内存:虽然共享内存本身操作并不会阻塞,但多个进程同时进行读写会出现冲突,因此需要使用信号量、文件锁之类会阻塞的系统调用去保障数据安全。可以想象一百多个FastCGI进程频繁去争取同个锁……
     内存映射:和共享内存同理,需要配合使用信号量之类的防冲突机制(实际就是POSIX版本的共享内存)。
     消息队列:支持阻塞与非阻塞两种试。
     Socket:支持阻塞与非阻塞;UDP发送数据可不阻塞。唯一支持跨服务器的IPC。
     管道:支持阻塞与非阻塞。匿名管道用于有亲缘关系的进程之间通信;命名管道比较类似Unix域类型的Socket。
结论
     在当前的使用场景下,TCP的非阻塞方式不适用;非阻塞的管道同理不适用;共享内存因为其设计与实现比较复杂,因此也不推荐使用(需要频繁使用信号量,且需要避免被死锁)。
UDP
     简单 - 无连接;数据报。无连接即不需要建立连接,服务端不需要listent和accept,客户端不需要connect;使用数据报,没有TCP的缓冲区,不会存在一次发送或接收只处理了半个数据报或多个数据报的问题。
     高效 - 更少的代码量,更快的运行速度。
     非阻塞 - 发送时不阻塞;接收操作默认阻塞。
     低可靠性 - 发送时成功与否是不可知的,目标服务器不可达或端口不可用时发送方一无所知;传输时,一个数据报可能会丢失,也可能会被误发多次;接收时如果服务端处理不及时,没来得及接收的数据将会被丢失。
PHP的UDP
     PHP网络操作有两个可用的扩展:stream 和 sockets,只是需要客户端功能的话还有一个额外的函数可用:fsockopen
     PHP的recvfrom操作是阻塞式的,并且不支持设置超时,如果一直没有消息程序一直保持阻塞会导致已连接的Redis或DB连接失效。
     要给UDP的recvfrom操作添加超时,有两个办法:
     1,先使用select操作去判断是否有数据到来,并且设置超时,如果有数据到来再调用recvfrom不会发生阻塞,如果没有数据到来达到超时时间select返回失败状态,这时程序再去执行连接的保活操作;
     2,依旧使用recvfrom去循环读取UDP消息,另外再使用一个后台常驻的SHELL脚本每隔几秒向PHP脚本进程发送信号,PHP脚本收到信号时如果正阻塞在recvfrom调用上的话会立刻返回失败,这时脚本可以去进行保活处理(山寨定时器)。
关于PHP的declare
     declare(ticks = 1) 启用PHP内部定时,每执行N条低级指令就执行一次指定的操作,PHP使用信号时需要开启。
     实测,declare不使用大括号时,作用范围仅为当前文件。
     在一个文件开头添加了declare,之后include了其它的文件,而在运行时include引入的文件内部的sleep、select、recvfrom类函数阻塞了进程时,向进程发送信号,函数调用会因为信号而中断返回,但信号回调函数却不会被调用。
     另外,declare的作用范围应当精确控制(用大括号包裹需要控制的代码),尽可能影响较少的代码,避免PHP的ticks机制带来不必要的性能问题。
消息队列(sysvmsg)
     基于消息 - 类似UDP基于数据报,发送接收以消息为单位。
     可阻塞也可非阻塞 - 发送与接收均可设置阻塞、非阻塞。
     内核维护队列 - 队列中的数据由系统保存,应用程序退出不会丢失数据。
     限制 - 默认队列大小只能存储两条最大尺寸的消息,且该设置为系统级;系统可以创建的消息队列也有限制(使用sysctl -a | grep kernel.msg查看)。
     失败 - msg_send 函数可以通过参数设置,当队列空间满了之后,再向队列发送数据是阻塞等待有空间时再定入,还是立刻返回失败,在异步任务消息的中转处理中我们选择后者。
     问题 - 缺少超时,同样可以使用信号来解决,定时发送信号给脚本以中断阻塞了的 msg_receive 函数调用。
直接使用UDP、消息队列而不使用Redis不是更简单吗?
     使用UDP、消息队列只用于转发消息,而不是直接执行异步任务,目的是减少PHP脚本中出现阻塞的概率,尽可能让UDP和sysvmsg的操作更高效。
     因为异步处理的业务逻辑中可能会存在类似cURL远程请求之类的较耗时的阻塞操作,导致脚本来不及接收新的UDP消息或提取sysvmsg队列中的数据,最终会引起数据丢失。
     因此使用UDP、sysvmsg只是用于转发消息,快速的将消息转存至Redis队列中,Redis相对UDP和sysvmsg而言可用空间较富裕,从而可以提供更大的缓冲余地。
     最终,即使使用了Redis,也需要注意:
         1,Redis专用,按业务数据分配专用的Redis实例,可以对数据提供保护,不同的实例可以设置不同的内存空间、回写频率、淘汰方式,当空间不足时也不会互相影响(Redis的使用规范应当也是如此,不同业务的数据尽量分开,可以方便未来扩容以及迁移,更重要的空间、淘汰策略、回写等配置都是实例级而不是DB级,且多开实例增加的内存开销相比数据使用空间较少,多个实例还能提供更高的QPS;继续引申,数据库应当合理进行垂直拆分,不同库可以分配到不同的实例上,而不同实例可以设置不同的实例级配置,例如根据业务重要程度决定是否做主从,是否做库表备份,分配不同的缓存空间,设置不同的binlog尺寸与数量等);
         2,异步处理的业务代码中,应当尽量避免耗时过久的操作,给可能阻塞的操作设置合理的定时,例如cURL请求指定连接和处理的超时,连接远程TCP服务设置连接超时;
         3,异步处理的业务代码中,不能出现 exit / die 此类可能会导致脚本中断执行的代码,并且应当捕获异常和运行时错误,因为woker退出后需要等待系统crontab自动拉起,存在数据丢失风险;
         4,异步处理的woker应当根据运行情况,比如队列数据有无积压,合理调整worker的数量。
UDP优化方案的问题
     UDP的不可靠性,网络拥堵或者服务异常时数据会彻底丢失,且FastCGI脚本作为发送方对此一无所知。
Sysvmsg优化方案的问题
     使用子进程定时发送信号中断 msg_receive 的等待,当启动了多个中转进程时,每个进程都会额外创建一个子进程,应当修改代码,改为父进程发送信号,子进程负责中转消息,或者使用 transfer1b 的方式。
关于System V
     V = Five, AT&T的UNIX, 第4个版本简称SVR4,血统纯正的UNIX。

演示代码地址:https://git.cnphper.com/weicky/php_tcp_server

课件3,使用 stream API 或 sockets 扩展基于 select 函数实现的TCP网络服务,并具备一定的并发处理能力。

文件描述符(fd)
     int 类型值
     Unix中一切都是文件
     普通文件, 标准输入输出, socket, 管道, …
     fcntl: 设置提取选项,比如设置阻塞非阻塞模式
     read,write: 读写数据
     close: 关闭
     php stream api: 对部分文件描述符相关函数的封装
     标准IO: fopen,fread,fwrite,fclose等,属于标准C对fd操作的封装,添加了读写缓存,类型为 FILE*,目的是为了减少IO频次
     PHP file handle: 普通文件实际上对应的是标准C文件类型(FILE *),socket文件应用的是文件描述符(int)
TCP状态变化
     连接建立
         [C]     -(syn)->        [S]     //syn-send, syn-recv
         [C]     <-(syn+ack)-    [S]     //established, syn-recv         [C]     -(ack)->        [S]     //established, established
 发送数据     [C]     -(psh)->        [S]     [C]     <-(ack)-        [S] 连接关闭     [A]     -(fin)->        [B]     //fin-wait-1, close-wait     [A]     <-(ack)-        [B]     //fin-wait-2, close-wait     [A]     <-(fin)-        [B]     //time-wait, last-ack     [A]     -(ack)->        [B]     //time-wait, closed     [A]                     [B]     //closed, closed
TCP阻塞的内部原因
     两个队列(半连接队列、已连接队列)
     两个缓冲区(读缓冲区、写缓冲区),大小可变化,通过TCP窗口大小告知对方,阻塞发生与否由缓冲区的可读写数据大小与TCP低水位值决定
TCP服务的相关限制值
     半连接队列 - sysctl -a | grep tcp_max_syn_backlog
     连接队列 - min(backlog, sysctl -a | grep somaxconn)
     读缓冲区 - sysctl -a | grep net.core.rmem_default, sysctl -a | grep tcp_rmem    #最小 默认 最大
     写缓冲区 - sysctl -a | grep net.core.wmem_default, sysctl -a | grep tcp_wmem    #最小 默认 最大
     TCP Windows Size - 接收端的缓冲区大小,在没有处理前可以连续发送的数据总大小
     MTU - IP包的最大传输单位,理论最大值是65535(uint16_t),广域网一般是1500
     MSS - TCP的最大分段大小,[MTU-IP包头长度-TCP固定包头长度],广域网一般是1460,WAN、WIFI、移动网络值不同,大于该值的数据将会拆分为多个IP包发送
多线程(进程) + 阻塞式IO 是否可行
     多线程编程的复杂性:主线程与其它线程如果存在共用数据,需要在每个使用到的地方使用线程锁、信号量等方法进行防冲突的保护处理,同时还需要避免出现死锁等问题。
     线程的开销依旧是一个问题,线程过多系统负载和内存占用都会上升,线程太少并发能力又会被限制。
     同个线程内如果出现读写的阻塞,在一个操作阻塞的同时,客户方有可能会有其它的请求到达,并发能力同样会被影响。
     结论:勉强可行,但需要做好线程管理,比如使用线程池,并且并发能力不算优秀(php-fpm)。
单线程 + IO复用 + 阻塞式IO 是否可行
     使用了IO利用去检测Socket的读写状态,在可读写的时候才去进行读写操作,这时候是不会阻塞的,那是不是可以不去额外将客户连接的Socket设置为非阻塞呢?
     考虑:当select/poll/epoll返回可读的时候,read操作是可以马上返回不会阻塞,但write操作却是可能会阻塞的,假如要写的数据大过于Socket的可用写缓冲区大小时(大出来很多),阻塞依旧会发生。
     结论:勉强可行,并发能力一般。
单线程 + IO复用 + 非阻塞IO
     实现相对简单,所有代码在同一个线程内运行,不存在数据冲突,不需要加锁。
     案例:Redis
多线程 + IO复用 + 非阻塞IO
     实现与使用都会相对复杂。运行效率高。
     案例:Boost.ASIO, Golang协程(Runtime)。
协程(Golang)
     go fn(args) //使用新协程运行 fn(args)
     不同协程可并行执行,这里说的并行是指主协程和新协程的代码可能是同一时间运行的(CPU核数越多可能性越大)。
     与其它语言(lua)不同,go的协程更像是线程,不同的协程可以同时运行,并且很可能确实是在不同的线程中运行(go程序运行时会启动多个线程,不同协程可能分配到不同的线程去执行,也可能分配到同一个线程执行)。
     多个协程之间类似多线程也存在数据冲突的问题,但Go提供了channel可以减少各种线程锁的使用频率(channel可以理解为线程安全的进程内部队列结构)。
     go中的io接口比如网络连接的read、write是阻塞式的,但因为有协程机制,所以编程者只需要使用不同协程去发起同步阻塞的请求即可,做到了运行效率和开发体验的平衡(运行效率与C++不相上下,开发效率高过C)。
     使用go的协程需要了解阻塞非阻塞、同步异步等网络编程方式的差异,具体解决数据冲突的能力。
     撒网之后还需要收网,并行化之后还需要串行化(比如同一个房间内的所有用户操作就需要串行化处理),局部串行化需要协程同步(通道、锁)。