多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
## workerman 进程管理 **author: xiak** **last update: 2021-12-20 10:11:11** ---- [TOC=3,8] ---- ### 进程模型 待研究子进程内部是否还可以再开子进程,应该是不行的 #### 进程相关的数据结构 ```php $worker->workerId \spl_object_hash($worker) $worker->id {0, 1, ..., n} n: $worker->count - 1 进程计数 Worker::$_workers [$worker->workerId => $worker, ...] Worker::$_pidMap [$worker->workerId => [pid => pid, ...], ...] Worker::$_idMap [$worker->workerId => [0 => pid, ..., n => pid], ...] n: $worker->count - 1 Worker::$_pidsToRestart [pid => pid, ...] $worker->_mainSocket $worker->connections [$connection->id => $connection, ...] $connection->id {1, 2, ..., n} n: PHP_INT_MAX ``` ---- #### 架构 ``` Worker::runAll(): static::initWorkers(); static::forkWorkers(); while (\count(static::$_pidMap[$worker->workerId]) < $worker->count) { 1. fork a $worker process 2. $worker->listen(); 3. static::$_pidMap = array(); 4. unset(static::$_workers[$key]); Remove other listener. 5. Timer::delAll(); 6. run $worker->run() in process } ``` ~~~ 主进程 Master worker 实例A 子进程 0 $worker->connections: [connection1, ...] 子进程 1 $worker->connections: [connection1, ...] ... worker 实例B 子进程 0 $worker->connections: [connection1, ...] 子进程 1 $worker->connections: [connection1, ...] ... ~~~ 1. 一个进程上只有一个 worker 实例,一个 worker 实例 可以有多个相同的 worker 进程,称为 worker 组进程 2. worker 组进程 都是相同的进程,监听在同一协议和同一端口上 3. worker 进程 即子进程,所有的 worker 进程 都是平级和相互独立的,都是直属于 主进程 Master 的子进程 4. 不同的 worker 实例 间是相互独立的,表示相互独立互不干扰的功能 理解了这个关系,分析问题时就不会绕进去了。我们的代码基本都是在 worker 实例 的各个回调方法上面。 ---- #### worker 实例 每一个 `$worker = new Worker();` 就是一个 worker 实例,每个 worker 实例的 所有进程出生时都是一样的(worker 实例的进程组),一样的出生,一样的 worker 实例,没有本质区别。(**注意 一个进程内只有一个 worker 实例**) ---- #### 主进程、子进程、worker 实例 区分 一个类里面,既是主进程,又是子进程,还有 worker 实例,经常绕进去了,绕晕了傻傻分不清了,这里有一个方法可以帮助分清这个情况: Workerman\\Worker::class 上: 1. 静态属性和静态方法 都是进程的,可以处于 子进程 或 主进程,可根据 来判断 static::$_masterPid === \posix_getpid() 2. 非静态属性和非静态方法,直接使用 $this 对象关键字访问时 都是 worker 实例,处于子进程 3. 任何时候都可以通过 static::$_masterPid === \posix_getpid() 判断当前是 主进程还是子进程部分 ---- #### 操作系统,程序,进程,socket 等之前的关系 操作系统是上帝,它管理着一切,代码是程序的人类直观语言描述,程序是做要做的事的一些列步骤,进程、线程 等是执行任务的使者,文件、网络、fd,socket 等则是执行任务过程可能会使用到的资源,它们都是上帝的。 业务需求 >代码\> 解释/编译 > 程序 > 进程/线程(进程数量取决于程序) > 系统调用 > 操作系统 > 执行 > 使用资源 > IO > 完成任务 link:[blog.csdn.net](https://blog.csdn.net/weixin_34025386/article/details/116487152) link:[blog.csdn.net](https://blog.csdn.net/diavid/article/details/81205072) > 程序、代码、进程 三者并不是有明显界限的客观实体,而是一种平行的概念。这需要能够理解计算机是如何工作的才能理解这种概念。 > 对操作系统来说,它并不关心某个进程是什么开发语言创建的,不论是什么语言只不过是上层的语法不一样而已,最终的系统调用都是一样的没有区别,所以对操作系统来说,编程语言是透明的,不存在的,进程没有这个关于是某种开发语言的属性,各种语言起的进程都是一样的,都是一样的公民。 ---- ### workerman 进程管理设计分析 1. workerman 不想子进程能够直接退出,它想 主进程监控子进程状态,只要子进程退出了,就直接自动拉起一个补上,不想随便就减少子进程数量,那是一开始定好的 2. workerman 支持平滑重启:依次平滑重启每个子进程(子进程收到信号后不在接受连接处理完剩下的业务后关闭),直至所有子进程重启完毕 3. 子进程 可以自己退出,但不能自己实现重启,因为 子进程 需要 主进程来拉起 4. 子进程 和 主进程 都是一样的信号监听,但可能 fork 了 后 使用之前的注册会有问题,所以 子进程 又重新设置了一遍信号,但处理方法还是和主进程一样的 ---- #### 主进程监控原理 1. 当子进程退出时,主进程能收到通知 2. 如果收到子进程退出通知时 主进程状态非 停止标记 static::STATUS_SHUTDOWN 说明 仍要运行,那就拉起新的 子进程(拉起数量取决于 现存子进程id 数量,要少了补齐) 如果 pid 在 static::$_pidsToRestart[$pid] 中 那么执行 static::reload() ``` if (isset(static::$_pidsToRestart[$pid])) { unset(static::$_pidsToRestart[$pid]); static::reload(); } ``` 3. 如果 主进程状态 为 停止标记,且 没有子进程 pids 了,那就停止主进程 ---- 我们想实现,自主控制 某个子进程的平滑重启,甚至动态增减子进程的数量,甚至必要时 彻底关闭服务,关闭所有子进程和主进程,不在启动。 ---- ### 信号处理 |信号|作用|说明| |--|--|--| | SIGINT | 停止 Worker::stopAll() | ctrl + c 终端按下,会向主进程发送此信号 | | SIGTERM | 停止 Worker::stopAll() | 没有任何控制字符关联 | | SIGHUP | 停止 Worker::stopAll() | command: `stop`| | SIGQUIT | **平滑停止** Worker::stopAll() | command: `stop-g`| | SIGUSR1 | 重载 Worker::reload() | `reload`| | SIGUSR2 | **平滑重载** | command: `reload -g` | | SIGIO | show connections | command: `connections` | | SIGIOT | show status| command: `status` | > 注意:信号管理只在 linux 上可用 |信号常量名称|信号值| |--|--| |SIGINT | 2 | |SIGTERM | 15 | |SIGHUP | 1 | |SIGTSTP | 20 | |SIGQUIT | 3 | |SIGUSR2 | 12 | |SIGUSR1 | 10 | |SIGIOT | 6 | |SIGIO | 29 | ---- ### 命令行管理 ~~~ Usage: php yourfile <command> [mode] Commands: start Start worker in DEBUG mode. Use mode -d to start in DAEMON mode. stop Stop worker. Use mode -g to stop gracefully. restart Restart workers. Use mode -d to start in DAEMON mode. Use mode -g to stop gracefully. reload Reload codes. Use mode -g to reload gracefully. status Get worker status. Use mode -d to show live status. connections Get worker connections. ~~~ > 将主进程 pid 保存到文件中,是为了在命令管理模式下得到主进程 pid 的 > 通过命令行管理服务进程,是通过 发进程信号 给 服务(启动文件名)的 主进程 进行的管理的 ~~~ cd /root/xiak-test/pulsar-demo/test/ php server.php start master process start_file=/root/xiak-test/pulsar-demo/test/server.php /vendor/workerman/_root_xiak-test_pulsar-demo_test_server.php.pid ~~~ ---- ### 进程管理 stopAll、stop、reload SIGINT SIGTERM SIGUSR1 static::$_gracefulStop = false(默认) SIGHUP SIGQUIT static::$_gracefulStop = true stop、reload 管理命令 时加 `-g` 参数表示使用 平滑停止和重载 ---- #### Worker::stopAll() 首先 不论 是否 主进程 还是 子进程 static::$_status = static::STATUS_SHUTDOWN; 通常是进程收到信号时调用,也可能是 在各种启动回调异常时或重载失败时调用 static::stopAll(250, $e); **主进程上:** 当子进程上执行 stopAll() 时,通常是 主进程收到 SIGHUP / SIGINT / SIGTERM 信号时调用的(通过 管理命令给主进程发信号) 1. $sign = static::$_gracefulStop ? SIGHUP : SIGINT 2. 向 现有全部 子进程发送 $sign (当子进程收到 SIGHUP / SIGINT 信号后 会执行 stopAll()) 3. 如果是 非平滑停止信号 2s 后再次 向 现有全部 子进程发送 SIGKILL (确保真的杀死了) 4. 1s 后检查 主进程的全部子进程 信号 0 ping 分析: 1. 主进程收到 的 停止信号 是 平滑 就给子进程发 平滑 的 停止信号 2. 主进程收到 的 停止信号 是 非平滑 就给子进程发 非平滑 的 停止信号 3. 非平滑停止信号 会确保 子进程真的被 kill 了(2s 后有检查,如果你不自己动手了解,那我就替你动手了) 4. 进程状态 标记为 关闭的,但 主进程 并不会立即退出 5. 决定 主进程 是否退出 是在 主进程的 进程监控中处理的 monitorWorkers 6. monitorWorkers: 等所有的 子进程 都退出完毕后,主进程才会真正退出 Worker::exitAndClearAll() 7. 主进程退出时 执行 Worker::onMasterStop() 回调,整个系统 exit 前的最后一个代码执行了 ---- **子进程上(worker):** 当子进程上执行 stopAll() 时,通常是 子进程收到主进程发过来的 SIGHUP / SIGINT 信号时调用的 1. 进程的 全部 worker 实例 执行 $worker->stop() 2. stop() 中: a. 执行 worker 实例 上的回调 $worker->onWorkerStop() b. 释放 worker 实例的 主端口监听:$worker->pauseAccept() 删除 _mainSocket 上的读事件, \fclose($this->_mainSocket)(如果有 socket 监听的话) c. static::$_gracefulStop = false (SIGINT) 时 关闭 worker 实例 上存在的 所有 socket 连接 3. 满足 static::$_gracefulStop = false || ConnectionInterface::$statistics['connection_count'] <= 0 时 全部 worker 实例 销毁 static::$_workers = array(); 全局事件对象 销毁 static::$globalEvent->destroy(); 进程退出 exit($code); 至此子进程结束掉了 > 注意这里的 `stop()` 方法并**不是停止进程**,而是 “停止” worker,包含 嵌套子 worker。(worker 上除了包含 **原始的一种 worker** 以外 还有 `Worker::runAll()` 后续生成的 “嵌套子 worker” ) 分析: 1. 子进程收到 SIGINT SIGTERM 后:(非平滑退出 static::$_gracefulStop:false) a. 执行每个 worker 实例 的回调 $worker->onWorkerStop() b. 释放 每个 worker 实例 socket 主端口监听,不再接受新连接 c. 关闭 每个 worker 实例 上存在的 所有 socket 连接 d. 不论进程上是否还有 连接实例 TcpConnection (不论连接是否为打开的),都直接退出进程 exit e. 要它退出停止,它不接受新的了连接,现有的也直接关闭,不论是否还在服务中,反正就是立马就要直接退出进程,这就是粗暴的非平滑停止 2. 子进程收到 SIGINT SIGTERM 后:(平滑退出 static::$_gracefulStop:true) a. 执行每个 worker 实例 的回调 $worker->onWorkerStop() b. 释放 每个 worker 实例 socket 主端口监听,不再接受新连接 c. 只有当进程上没有 连接实例 TcpConnection (不论连接是否为打开的)时,才退出进程 exit d. 此后,进程上的每个连接关闭时都会执行 Worker::stopAll() e. 这样 这个子进程最终在没有 连接时,会自动 退出进程 exit f. 要它退出停止,它不接受新的了连接,现有的也等它自己关闭 它只会在等到没有 连接实例 TcpConnection (不论连接是否为打开的) 时,即等为所有客户端都提供服务完毕后,才会退出进程,这就是 平滑停止啊意思啊 >[tip] 注意这里的 连接实例 TcpConnection (不论连接是否为打开的),如果一个连接确定不再使用,不会再重连接了,那么需要 释放 TcpConnection 连接实例,不然会影响 平滑重启机制 >[tip] 注意这里的 子进程上的 **全部 worker 实例** 其实只有**一个(一种) worker 实例**。( Worker::forkOneWorkerForLinux() 中做了处理) ---- #### Worker::reload() 进程上执行 reload() 时,通常是 主进程收到 SIGQUIT / SIGUSR1 信号时调用的(通过 管理命令给主进程发信号),和子进程收到父进程发的信号时, 或者 主进程监控 时自主调用 ```php case \SIGQUIT: case \SIGUSR1: static::$_gracefulStop = $signal === \SIGQUIT; static::$_pidsToRestart = static::getAllWorkerPids(); static::reload(); ``` **主进程上:** 1. 进程状态不为 重载中 和 停止时 设置 进程状态 为 Worker::$STATUS_RELOADING 重载中 ,调用 Worker::onMasterReload() , Worker::initId() 初始化 Worker::$_idMap; 2. 遍历所有 worker 实例 的所有进程,判断 $worker->reloadable === true 实例 是否可被重载(默认 true): a. 可被重载:记录 每个 可重载的 worker 实例的 所有可以重载的进程id $reloadable_pid_array b. 不可被重载:给 每个 不可重载的 worker 实例的 每一个进程 发 SIGQUIT / SIGUSR1 信号 (所有可以重载的进程id 为空) 3. 计算可重载进程ids: 所有可以重载的进程id 与 static::$_pidsToRestart 取交集,其实就是 与 所有 worker 进程id 取交集 static::$_pidsToRestart = \array_intersect(static::$_pidsToRestart, $reloadable_pid_array); 4. 如果有 计算可重载进程ids a. 向其中一个发 SIGQUIT / SIGUSR1 信号 b. 非平滑信号 2s 后 kill 这个进程 5. 如果没有 计算可重载进程ids,只有进程状态为非停止,就设置为 运行中 分析: 1. 可被重载时 向 可重载的 worker 实例的 一个进程 发 SIGQUIT / SIGUSR1 信号,并在主进程上记录了 所有 要重启的 进程id a. 当这个 worker 实例 进程(子进程)收到信号,并成功退出后,主进程监控到了 退出进程id b. 主进程非停止状态,有进程退出,会拉起新进程替补 c. 当发现退出的进程id 在 static::$_pidsToRestart 中,则 执行 unset(static::$_pidsToRestart[$pid]); static::reload() c. 这样直到主进程第一次收到重载信号时的那一批旧进程全部完成退出更替(退出一个,启动一个,退出一个,...) d. 最终实现了,旧进程的依次退出启动,从而最大限度地降低重载过程中服务进程减少对系统的影响 1. 不可被重载时 向 不可重载的 worker 实例的 所有进程 发 SIGQUIT / SIGUSR1 信号 a. 子进程收到信号后,由于 其实例 是不可重载的,所以只是 调用了 $worker->onWorkerReload() ,进程并没有重启 b. worker 实例 不支持重载 时,重载信号基本没什么用了 ---- **子进程上(worker):** 1. 取一个 worker 实例 (其实子进程的 Worker::$_workers 上也只有一个当前实例) 2. 调用 $worker->onWorkerReload() ,如果有异常 则调用 Worker::stopAll(250, $e); 3. $worker->reloadable === true Worker::stopAll() ---- ### stop/restart 命令的区别 这两个命令都在 一个 switch 条件分支上,都是 向主进程发送 停止信号(平滑停止 和 非平滑停止): 1. 向主进程发送停止信号 2. 不断检测主进程是否停止 \usleep(10000) 3. 当 非平滑停止信号时,超过 5s 主进程还未停止,那么 stop fail 停止失败 4. 当 主进程停止成功了,两个命令的区别就来了: a. $command === 'stop' exit b. $command === 'restart' 继续向下执行,相当于 使用 `start` 命令了,效果就是 旧服务停止后,又在当前命令行下重新启动一次(不过这个有个缺陷,-g -d 无法同时设置) 总结:restart = stop + start , 但支持的不好,-g -d 无法同时设置 ---- ### restart 与 reload 的区别 #### restart 重启,重新启动,停止整个服务后,再重新启动,停止有 平滑停止 和 非平滑停止,服务完全停止时 会中断服务 ---- #### reload 重载(平滑重启) 让旧的子进程依次停止退出,退出一个旧进程,会立马补一个新的,新旧进程一个接一个交替,相当于不会中断服务的重启,停止有 平滑 和 非平滑停止 ---- ### 什么是平滑重启? 平滑重启不同于普通的重启,平滑重启可以做到在不影响用户的情况下重启服务,以便重新载入PHP程序,完成业务代码更新。 平滑重启一般应用于业务更新或者版本发布过程中,能够避免因为代码发布重启服务导致的暂时性服务不可用的影响。 注意:只有子进程运行过程中载入的文件支持reload,主进程载入的文件不支持reload。或者说Worker::runAll执行完后workerman运行过程中动态加载的文件支持reload,Worker::runAll执行前就载入的文件代码不支持reload ### 平滑重启原理 WorkerMan分为主进程和子进程,主进程负责监控子进程,子进程负责接收客户端的连接和连接上发来的请求数据,做相应的处理并返回数据给客户端。当业务代码更新时,其实我们只要更新子进程,便可以达到更新代码的目的。 当WorkerMan主进程收到平滑重启信号时,主进程会向其中一个子进程发送安全退出(让对应进程处理完毕当前请求后才退出)信号,当这个进程退出后,主进程会重新创建一个新的子进程(这个子进程载入了新的PHP代码),然后主进程再次向另外一个旧的进程发送停止命令,这样一个进程一个进程的重启,直到所有旧的进程全部被置换为止。 我们看到平滑重启实际上是让旧的业务进程逐个退出然后并逐个创建新的进程做到的。为了在平滑重启时不影响客用户,这就要求进程中不要保存用户相关的状态信息,即业务进程最好是无状态的,避免由于进程退出导致信息丢失。 平滑要做到两点: 1. 现有的连接,正在服务的不能影响 2. 重启过程中 不中断在线服务,可以降低服务吞吐能力,但不能完全丧失服务能力。 ---- ### 嵌套子 worker ```php <?php $worker = new Worker(); $worker->onWorkerStart(function ($worker) { $inner_text_worker = new Worker('Text://0.0.0.0:5678'); $inner_text_worker->onMessage = function($connection, $buffer) { }; $inner_text_worker->listen(); }); Worker::runAll(); ``` ---- ### 记一次 Crontab 的计划任务失效的问题 ~~~ 问题: Crontab 的计划任务都失效了? 1. application/daemon/test/start.php 中 $worker->onWorkerExit (父进程上下文) 中,用了 new workerBusiness () ,而其 __construct 中有 new Crontab() 2. 即此时 父进程 执行了一次 new Crontab() ,然后 forkWorkers 子进程,导致 fork 过来的 子进程的 Crontab 也污染了,所以子进程 在用 计划任务就用不了。 3. 根本原因:父进程 不能使用 new Crontab(),否则会导致子进程 Crontab 无法再使用了 问题引入原因: 上周六代码调整 将 new Crontab() 引入到 __construct 中去了(统一 默认定时器 解决内存等问题) 修复方案: $worker->onWorkerExit 父进程的 上下文 内 不在 new workerBusiness 了,改用 $worker->workerBusiness 就行了 ~~~ > 这个问题只在 子进程 退出后 在被拉起时才会出现,所以 28号运行一天后,重启的子进程的计划任务就失效了 问题代码: ```php $worker->onWorkerExit = function ($worker, $status, $pid) use ($_started, $spugEnvKey) { call_user_func([getWorkerBusinessInstance($worker, $_started['file']), 'onWorkerExit'], $worker, $status, $pid, $spugEnvKey); }; ``` 修复为: ```php $worker->onWorkerExit = function ($worker, $status, $pid) use ($_started, $spugEnvKey) { call_user_func([$worker->workerBusiness, 'onWorkerExit'], $worker, $status, $pid, $spugEnvKey); }; ``` ---- ### 信号处理 给进程发送信号,会立即打断进程的睡眠。比如 进程睡眠 50s ,但是向其发送了信号,睡眠立即中断完成(睡眠立即结束),代码继续向下执行。 ```php <?php function sig() { echo 'sig' . PHP_EOL; } pcntl_signal(SIGTERM, "sig"); // kill -15 pid while (1) { // pcntl_signal_dispatch(); // Calls signal handlers for pending signals echo time() . PHP_EOL; sleep(50); // 信号会打断睡眠 // 注意:一次信号只能打断一个睡眠,所以 worker 中用 sleep 有可能阻塞(所以不建议用) // sleep(50); } ``` [PHP: sleep - Manual](https://www.php.net/manual/zh/function.sleep.php) > 如果函数的调用被一个信号中止,sleep() 会返回一个非零的值。在 Windows 上,该值总是 192(即 Windows API 常量 WAIT_IO_COMPLETION 的值)。其他平台上,该返回值是剩余未 sleep 的秒数。 [PHP: stream_select - Manual](https://www.php.net/manual/zh/function.stream-select) > On success stream_select() returns the number of stream resources contained in the modified arrays, which may be zero if the timeout expires before anything interesting happens. On error false is returned and a warning raised (this can happen if the system call is interrupted by an incoming signal). 成功时,stream_select() 返回修改后数组中包含的流资源数量,如果超时时间已过,在发生任何有趣的事情之前,该数量可能为零。如果出错,则返回 false 并发出警告(如果系统调用被接收到的信号中断,则可能发生这种情况)。 **(同样也会被信号打断)** ---- ### 参考 https://www.workerman.net/doc/workerman/install/start-and-stop.html [如何在php后端及时推送消息给客户端-workerman社区](https://www.workerman.net/q/508) [linux每日命令(34):ps命令和pstree命令 - 听风。 - 博客园](https://www.cnblogs.com/huchong/p/10065246.html) [Workerman开源框架的作者 - luckc# - 博客园](https://www.cnblogs.com/luckcs/articles/6783249.html) [Life and Death of a Linux Process :: Tech Notes by Natan Yellin](https://natanyellin.com/posts/life-and-death-of-a-linux-process/)(进程生从何来,死往何去)