FPM模式的生命周期

image 2024 06 10 00 12 43 527
Figure 1. 图7-11 FPM模式的生命周期

FPM(FastCGI Process Manager)是一个 FastCGI 进程管理器,对于 PHP 5.3.3 之前的 PHP 来说,它只是一个补丁包。从 PHP 5.3.3 开始,PHP 集成了 PHP-FPM。PHP-FPM 提供了更好的 PHP 进程管理方式,可以有效控制内存和进程,支持平滑重启 PHP 及重载 PHP 配置。

与 CLI 模式类似,FPM 模式的生命周期也有 5 个阶段,但是又与 CLI 模式的生命周期不同,因为 FPM 是常驻内存的进程,所以其模块初始化只做一次,便进入循环,而模块关闭在进程退出时也只做一次,如图7-11所示。

  1. 调用 php_module_startup,加载所有模块。

  2. 进入循环,调用 fcgi_accept_request 实际调用的是 accept,阻塞等待请求;如果有请求进来,会被唤起,进入 php_request_startup,初始化请求。为了防止多个进程对 accept 进行抢占,出现 “惊群” 情况,增加了锁机制:

    FCGI_LOCK(req->listen_socket);
    req->fd = accept(listen_socket, (struct sockaddr *)&sa,
        &len);
    FCGI_UNLOCK(req->listen_socket);

    但是细心的读者可以发现,FCGI_LOCK/FCGI_UNLOCK 在 Linux 下已经没有实现了:

    #  define FCGI_LOCK(fd)
    #  define FCGI_UNLOCK(fd)

    这是因为在 Linux 2.6 内核上,阻塞版本的 accept 系统调用已经不存在 “惊群” 了。

  3. 进入 php_execute_script,对脚本执行编译。

  4. 调用 php_request_shutdown 关闭请求,继续进入循环。

  5. 如果进程退出,调用 php_module_shutdown 关闭所有模块。

  6. 如果请求次数大于 max_requests,则跳转 5。

在 Linux 2.6 内核上,阻塞版本的 accept 系统调用已经不存在 “惊群” 了。大家可以写一个简单的程序测试下,并在父进程中绑定、监听,然后 fork 出子进程,所有的子进程都会尝试接受(accept)这个监听句柄。这样,当新连接过来时,大家会发现,仅有一个子进程返回新建的连接,其他子进程继续休眠在 accept 调用上,没有被唤醒。

了解了 FPM 的生命周期,下面具体分析一下整个过程。

多进程管理

PHP-FPM 是多进程的服务,其中有一个 master 进程(做管理工作)和多个 worker 进程(处理数据请求)。下面我们从多进程管理角度对 PHP-FPM 展开阐述,首先讨论 master 进程和 worker 进程是如何创建的,然后讨论进程之间是如何通信的,比如 worker 进程意外退出,master 进程是如何感知并重新创建新的 worker 进程的。

进程创建

我们以 Nginx+PHP-FPM 方式为例,讲一下整个 Web 请求的过程。一般情况下,Nginx 会根据服务器的 CPU 内核数设置 worker 的进程数,而 PHP-FPM 的进程有三种设置方式:static、dynamic 和 ondemand,可以在 php-fpm.conf 里面设置:

pm = static   //其他:dynamic或者ondemand
static 模式

static 模式始终会保持一个固定数量的子进程,这个数量由 pm.max_children 定义,比如线上,我们可以将其设置为 512 个 worker,我们可以观察 PHP-FPM 的进程空闲数,如图7-12所示。

image 2024 06 10 10 07 33 750
Figure 2. 图7-12 PHP-FPM空闲数

从图7-12可以看出,随着请求量的变化,PHP-FPM 的空闲数也发生了变化。

dynamic 模式

子进程的数量是动态变化的。启动时,会生成固定数量的子进程,可以理解成最小子进程数,通过 pm.start_servers 控制,而最大子进程数则由 pm.max_children 控制,子进程数会在 pm.start_servers~pm.max_children 范围内变化,另外,闲置的子进程数还可以由 pm.min_spare_servers 和 pm.max_spare_servers 两个配置参数控制。换句话说,闲置的子进程也可以有最小数目和最大数目,而如果闲置的子进程超出了 pm.max_spare_servers,则会被杀掉。

ondemand 模式

这种模式和 dynamic 模式正好相反,把内存放在第一位,每个闲置进程在持续闲置了 pm.process_idle_timeout 秒后就会被杀掉。有了这个模式,到了服务器低峰期,内存自然会降下来,如果服务器长时间没有请求,就只会有一个 PHP-FPM 主进程,当然其弊端是,遇到高峰期或者 pm.process_idle_timeout 的值太小的话,无法避免服务器频繁创建进程的问题。

3 种模式对应的定义如下:

enum {
    PM_STYLE_STATIC = 1,
    PM_STYLE_DYNAMIC = 2,
    PM_STYLE_ONDEMAND = 3
};

我们了解了 PHP-FPM 的 3 种运行模式,接下来继续介绍整个 webserver 的运行过程,如图7-13所示。

image 2024 06 10 10 20 21 811
Figure 3. 图7-13 Client/Nginx/PHP-FPM通信示意图

从图 7-13 可以看到,Client 通过 HTTP 方式请求 Nginx,请求由 Nginx 的 worker 进行处理,转成对应的 FastCGI,请求 FPM, accept 由 FPM 的 worker 进程处理,执行完毕再返回给 Nginx, Nginx 再进一步返回给 Client。

下面我们详细讨论一下 PHP-FPM 进程是怎么启动的。我们使用 gdb 来启动 PHP-FPM,其中 PHP-FPM 在 sbin 目录下:

gdb sbin/php-fpm
(gdb) b main
(gdb) r -y etc/php-fpm.conf

在 main 函数入口处增加断点,然后使用 r -y etc/php-fpm.conf 指定加载的配置文件,此时启动的进程并不是 master 进程,而是 calling process 进程,calling process 进程会 fork 出 master 进程,并退出。为了能够跟随 master 进程,我们使用 gdb 里面的命令,以对子进程进行跟踪:

(gdb) set follow-fork-mode child

输入 c 命令(continue):

(gdb) c
Continuing.
Breakpoint 2, 0x0000003dcbcacd14 in fork () from /lib64/libc.so.6
(gdb) bt
#0  0x0000003dcbcacd14 in fork () from /lib64/libc.so.6
#1  0x0000000000aa234b in fpm_unix_init_main () at /root/php7/book/php-7.1.0/sapi/
    fpm/fpm/fpm_unix.c:495
#2  0x0000000000a8c04f in fpm_init (argc=3, argv=0x7fffffffe178, config=0x7fffffffe479
    "etc/php-fpm.conf", prefix=0x0, pid=0x0, test_conf=0, run_as_root=0, force_
    daemon=-1,
    force_stderr=0) at /root/php7/book/php-7.1.0/sapi/fpm/fpm/fpm.c:61
#3  0x0000000000a997a5 in main (argc=3, argv=0x7fffffffe178) at /root/php7/book/
    php-7.1.0/sapi/fpm/fpm/fpm_main.c:1
(gdb) n
Single stepping until exit from function fork,
which has no line number information.
[New process 16299]
[Thread debugging using libthread_db enabled]
[Switching to Thread 0x7ffff7fe0700 (LWP 16299)]
0x0000003dcc005810 in __nptl_set_robust () from /lib64/libpthread.so.0

我们可以看到,calling process 会在 fpm-init 函数中将 master 进程 fork 出来,同时自己退出。对应的代码如下:

pid_t pid = fork();
switch (pid) {
    case -1 : /* error */
        zlog(ZLOG_SYSERROR, "failed to daemonize");
        return -1;
    case 0 : /* children */
        close(fpm_globals.send_config_pipe[0]);
        /* close the read side of the pipe */
        break;
    default : /* parent */
        close(fpm_globals.send_config_pipe[1]);
        /* close the write side of the pipe */
        ……
        exit(FPM_EXIT_SOFTWARE);

我们知道,对于父进程(calling process), fork 返回的 pid 是 master 进程的 pid,会走到 default 逻辑中,最终会退出进程;而 master 进程会在 fpm_run 函数中 fork 子进程(worker 进程), gdb 信息如下:

Breakpoint 2, 0x0000003dcbcacd14 in fork () from /lib64/libc.so.6
(gdb) bt
#0  0x0000003dcbcacd14 in fork () from /lib64/libc.so.6
#1  0x0000000000a8cfc1 in fpm_children_make (wp=0x1315430, in_event_loop=0, nb_to_
    spawn=0, is_debug=1) at /root/php7/book/php-7.1.0/sapi/fpm/fpm/fpm_children.c:400
#2  0x0000000000a8d259 in fpm_children_create_initial (wp=0x1315430) at /root/php7/
    book/php-7.1.0/sapi/fpm/fpm/fpm_children.c:453
#3  0x0000000000a8c1a2 in fpm_run (max_requests=0x7fffffffdf3c) at /root/php7/book/
    php-7.1.0/sapi/fpm/fpm/fpm.c:101
#4  0x0000000000a998c8 in main (argc=3, argv=0x7fffffffe178) at /root/php7/book/
    php-7.1.0/sapi/fpm/fpm/fpm_main.c:1

在函数 fpm_children_make 中,我们可以看到 static、dynamic、ondemand 这 3 种模式的不同之处,代码如下:

if (wp->config->pm == PM_STYLE_DYNAMIC) {
    /* dynamic模式下,先启动pm_start_servers数量的worker进程,根据请求动态变化 */
        if (! in_event_loop) { /* starting */
            max = wp->config->pm_start_servers;
        } else {
            max = wp->running_children + nb_to_spawn;
        }
    } else if (wp->config->pm == PM_STYLE_ONDEMAND) {
    /* ondemand模式下,启动时并不创建worker进程,按需启动 */
        if (! in_event_loop) { /* starting */
        max = 0; /* do not create any child at startup */
    } else {
        max = wp->running_children + nb_to_spawn;
    }
} else { /* PM_STYLE_STATIC */
/* static模式下,启动固定数量的worker进程 */
    max = wp->config->pm_max_children;
}

从代码中可以看出,在 static 模式下,会走到最后一个 else,进程数为 pm_max_children;在 dynamic 模式下,启动时,进程数为 pm_start_servers,而在 ondemand 模式下,启动时,进程数为 0。

接下来,master 会根据需要启动的子进程数进行 fork,代码如下:

/*
* fork children while:
*   - fpm_pctl_can_spawn_children : FPM is running in a NORMAL state (aka not
    restart, stop or reload)
*    - wp->running_children < max  : there is less than the max process for
    the current pool
*    - (fpm_global_config.process_max < 1 || fpm_globals.running_children <
    fpm_global_config.process_max):
*     if fpm_global_config.process_max is set, FPM has not fork this number of
    processes (globaly)
*/
while (fpm_pctl_can_spawn_children() && wp->running_children < max &&
    (fpm_global_config.process_max  <  1  ||
    fpm_globals.running_children  <  fpm_global_config.process_max)) {
    warned = 0;
    child = fpm_resources_prepare(wp);
    if (!child) {
        return 2;
    }
    pid = fork();

到这里我们明白了,php-fpm 启动时,首先启动一个 calling process,然后由 calling process 创建 master 进程,master 进程根据需要创建的子进程数创建 work 进程,其中 master 进程的 title 为 php-fpm: master process,而 worker 进程的名称为 php-fpm:pool name,其中 name 在 php-fpm.conf 里面设置:

; pool name ('www' here)
[www]

子进程修改名称的代码在函数 fpm_env_init_child 中:

char *title;
spprintf(&title, 0, "pool %s", wp->config->name);
fpm_env_setproctitle(title);
efree(title);

整个 php-fpm 进程的创建过程如图7-14所示。

image 2024 06 10 10 35 47 495
Figure 4. 图7-14 PHP-FPM进程的创建过程

讨论完进程创建的过程,下面分析一下进程是如何管理的。

进程管理

woker 创建完成后,对请求的处理工作都会由 worker 进程来进行,而 master 进程负责对 worker 进程的监控和管理,比如 php-fpm reload 和 php-fpm stop 分别用来重新加载和停止 FPM。这部分工作是通过信号机制进行的,比如我们执行 reload 命令时,对主进程发送了 SIGUSR2 信号。下面我们对 PHP-FPM 中的 master 进程和 worker 进程的信号分别进行阐述。

首先说一下 master 进程的信号,其初始化工作是在 fpm_init 中实现的,具体函数为 fpm_signals_init_main,对应的代码如下:

int fpm_signals_init_main() /* {{{ */
{
    struct sigaction act;
    //创建管道并设置为非阻塞模式
    if (0 > socketpair(AF_UNIX, SOCK_STREAM, 0, sp)) {
        zlog(ZLOG_SYSERROR, "failed to init signals: socketpair()");
        return -1;
    }
    if (0 > fd_set_blocked(sp[0], 0) || 0 > fd_set_blocked(sp[1], 0)) {
        zlog(ZLOG_SYSERROR, "failed to init signals: fd_set_blocked()");
        return -1;
    }
    //代码省略//

    memset(&act, 0, sizeof(act));
    act.sa_handler = sig_handler; //设置信号函数
    sigfillset(&act.sa_mask);
    //注册SIGTERM、SIGINT、SIGUSR1、SIGUSR2、SIGCHLD、SIGQUIT信号
    if (0 > sigaction(SIGTERM,  &act, 0) ||
        0 > sigaction(SIGINT,   &act, 0) ||
        0 > sigaction(SIGUSR1,  &act, 0) ||
        0 > sigaction(SIGUSR2,  &act, 0) ||
        0 > sigaction(SIGCHLD,  &act, 0) ||
        0 > sigaction(SIGQUIT,  &act, 0)) {
            zlog(ZLOG_SYSERROR, "failed to init signals: sigaction()");
            return -1;
    }
    return 0;
}

该函数主要做了两件事情。

  1. 创建了一个双向的管道 sp,并将其设置为非阻塞模式。

  2. 设置了 SIGTERM、SIGINT、SIGUSR1、SIGUSR2、SIGCHLD、SIGQUIT 信号的回调函数 sig_handler,该函数的实现如下:

static void sig_handler(int signo) /* {{{ */
{
    //对几种信号,使用char来表示
    static const char sig_chars[NSIG + 1] = {
        [SIGTERM] = 'T',
        [SIGINT]  = 'I',
        [SIGUSR1] = '1',
        [SIGUSR2] = '2',
        [SIGQUIT] = 'Q',
        [SIGCHLD] = 'C'
    };
    char s;
    int saved_errno;
    //保证是master进程
    if (fpm_globals.parent_pid ! = getpid()) {
        return;
    }

    saved_errno = errno;
    s = sig_chars[signo];
    //写入之前创建的管道的1端口
    zend_quiet_write(sp[1], &s, sizeof(s));
    errno = saved_errno;
}

从上述代码中,我们可以看到,当 master 进程收到信号时,会将其转换为对应的 char,然后将 char 写入管道的一端,那么谁读取呢?答案是 fpm_event_loop 函数,代码如下:

void fpm_event_loop(int err) /* {{{ */
{
    static struct fpm_event_s signal_fd_event;

    //保证是master进程
    if (fpm_globals.parent_pid ! = getpid()) {
        return;
    }
    //其中fpm_signals_get_fd()获取的是sp[0],注册的回调函数为fpm_got_signal
    fpm_event_set(&signal_fd_event, fpm_signals_get_fd(), FPM_EV_READ, &fpm_got_signal, NULL);

从代码中可以看出,该函数从管道的另一端读取数据,并回调函数 fpm_got_signal。fpm_got_signal 的实现如下:

static void fpm_got_signal(struct fpm_event_s *ev, short which, void *arg) /* {{{ */
{
    //代码省略//
    do {
        res = read(fd, &c, 1);
    //代码省略//
    switch (c) {
        case 'C' :                   /* SIGCHLD */
        //这个信号由worker进程发出,对相应的worker进程做一些善后工作
        fpm_children_bury();
        break;
        case 'I' :                   /* SIGINT  */
        //收到SIGINT信号,master进程和worker进程退出
        fpm_pctl(FPM_PCTL_STATE_TERMINATING, FPM_PCTL_ACTION_SET);
        break;
        case 'T' :                   /* SIGTERM */
        // 收到SIGTERM信号,master进程和worker进程退出
        fpm_pctl(FPM_PCTL_STATE_TERMINATING, FPM_PCTL_ACTION_SET);
        break;
        case 'Q' :                   /* SIGQUIT */
        // 收到SIGQUIT信号,master进程和worker进程退出
        fpm_pctl(FPM_PCTL_STATE_FINISHING, FPM_PCTL_ACTION_SET);
        break;
        case '1' :                   /* SIGUSR1 */
        //代码省略//
        //收到SIGUSR1信号,重新打开日志文件,并重启worker进程
        ret = fpm_log_open(1);
        //代码省略//
        break;
        case '2' :                   /* SIGUSR2 */
        //重启worker进程
        fpm_pctl(FPM_PCTL_STATE_RELOADING, FPM_PCTL_ACTION_SET);
        break;
        }
        //代码省略//
    } while (1);
    return;
}

从代码中可以看出以下几点。

  1. 对于 SIGCHLD 信号,该信号是由 worker 退出时发送的,master 进程收到这个信号后调用 fpm_children_bury 函数对 worker 进程进行善后工作;同时调用 fpm_children_make 函数按照不同模式启动 worker 进程。

  2. 对于 SIGUSR1 信号,调用的是 fpm_log_open 函数,重新打开日志文件,然后 fpm_pctl_kill_all 杀掉 worker 进程;这时候又会收到 SIGCHLD 信号,进行步骤1。

    在大流量请求的情况下,切分日志时,会向 php-fpm 发送 SIGUSR1 信号,此时会有批量的 worker 进程被杀死,在重启完毕前,worker 进程数会瞬间变少,这时候会出现请求响应变慢的情况。

  3. 对于 SIGINT、SIGTERM、SIGQUIT 和 SIGUSR2 信号,调用的都是 fpm_pctl 函数,该函数有两个参数,第一个参数表示状态值,第二个参数表示操作类型,对应代码如下:

enum {
    FPM_PCTL_STATE_UNSPECIFIED,
    FPM_PCTL_STATE_NORMAL,
    FPM_PCTL_STATE_RELOADING,
    FPM_PCTL_STATE_TERMINATING,
    FPM_PCTL_STATE_FINISHING
};

enum {
    FPM_PCTL_ACTION_SET,
    FPM_PCTL_ACTION_TIMEOUT,
    FPM_PCTL_ACTION_LAST_CHILD_EXITED
};

收到 SIGUSR2 信号,执行 fpm_pctl_exec 函数,该函数内部调用 C 语言 execvp 函数启动 FPM。收到 SIGQUIT、SIGINT、SIGTREM 信号,执行 fpm_pctl_exit 函数实现主进程的退出。

到此我们了解了 master 进程对信号的处理工作,接下来我们讨论一下 worker 进程的信号处理,其实现是在函数中调用 fpm_signals_init_child,具体代码如下:

int fpm_signals_init_child() /* {{{ */
{
    //代码省略//
    act.sa_handler = &sig_soft_quit; //信号回调函数
    act.sa_flags |= SA_RESTART;

    act_dfl.sa_handler = SIG_DFL; //信号回调函数为SIG_DFL,默认处理
    //关闭继承的管道的两端
    close(sp[0]);
    close(sp[1]);

    if (0 > sigaction(SIGTERM,  &act_dfl,  0) ||
        0 > sigaction(SIGINT,   &act_dfl,  0) ||
        0 > sigaction(SIGUSR1,  &act_dfl,  0) ||
        0 > sigaction(SIGUSR2,  &act_dfl,  0) ||
        0 > sigaction(SIGCHLD,  &act_dfl,  0) ||
        0 > sigaction(SIGQUIT,  &act,      0)) {
                return -1;
        }
        //…省略代码…//
    }

可以看出,SIGTERM、SIGINT、SIGUSR1、SIGUSR2、SIGCHLD 的信号回调函数为 SIG_DFL,即默认处理;而 SIGQUIT 的信号回调函数为 sig_soft_quit,其实现如下:

static void sig_soft_quit(int signo) /* {{{ */
{
    int saved_errno = errno;

    close(0);
    if (0 > socket(AF_UNIX, SOCK_STREAM, 0)) {
        zlog(ZLOG_WARNING, "failed to create a new socket");
    }
    fpm_php_soft_quit();
    errno = saved_errno;
}

void fpm_php_soft_quit() /* {{{ */
{
    fcgi_terminate();
}

void fcgi_terminate(void)
{
    in_shutdown = 1;
}

从代码中可以看出,该函数会将 in_shutdown 值设为 1,而 in_shutdown 控制子进程接收客户端请求操作,当 in_shutdown 等于 1 的时候,表明不再接收请求,则子进程会退出,关闭 CGI,释放资源等操作,做到了 “软” 关闭。

计分板

为了熟练地掌握各 woker 进程的工作情况,FPM 提供了一个计分板的功能,其核心结构体为 fpm_scoreboard_s 和 fpm_scoreboard_proc_s,具体定义如下:

struct fpm_scoreboard_s {
    union { //保证原子性的锁机制
            atomic_t lock;
            char dummy[16];
    };
    char pool[32]; //worker名称
    int pm; //运行模式
    time_t start_epoch; //开始的时间
    int idle; //process的空闲数
    int active; //process的活跃数(工作中的)
    int active_max; //最大活跃数
    unsigned long int requests; //请求次数
    unsigned int max_children_reached; // 达到最大进程数限制的次数
    int lq; // 当前listen queue的请求数
    int lq_max; //listen queue的大小
    unsigned int lq_len; //listen queue的长度
    unsigned int nprocs; //process的总数
    int free_proc; //从process的列表遍历下一个空闲对象的开始下标
    unsigned long int slow_rq; //慢请求数
    struct fpm_scoreboard_proc_s *procs[]; //计分板详情
};

struct fpm_scoreboard_proc_s {
    union { //保证原子性的锁机制
        atomic_t lock;
        char dummy[16];
    };
    int used; //是否被使用
    time_t start_epoch;
    pid_t pid; //进程id
    unsigned long requests;
    enum fpm_request_stage_e request_stage; //处理请求阶段,会在7.4.3节阐述
    struct timeval accepted;      //accept请求的时间
    struct timeval duration;      //脚本执行的时间
    time_t accepted_epoch;        //accept请求时间戳(秒)
    struct timeval tv;            //活跃时间
    char request_uri[128];        //请求URI
    char query_string[512];       //请求参数
    char request_method[16];      //请求方法
    size_t content_length;        //请求长度
    char script_filename[256];
    char auth_user[32];
#ifdef HAVE_TIMES
    struct tms cpu_accepted;
    struct timeval cpu_duration;
    struct tms last_request_cpu;
    struct timeval last_request_cpu_duration;
#endif
    size_t memory; //内存使用大小
};

从代码可以看出,fpm_scoreboard_s 结构记录 FPM 所有 worker 进程的汇总统计信息,而 fpm_scoreboard_proc_s 对应的是各 worker 进程的详细信息。FPM 提供了 3 个函数来统计计分。

  1. fpm_scoreboard_update 函数:修改计分板里的各指标,为了保证原子性,使用了锁机制 fpm_spinlock,分别对两种 action 进行处理:

    #define FPM_SCOREBOARD_ACTION_SET 0 //重置操作
    #define FPM_SCOREBOARD_ACTION_INC 1 //增加操作

    在 FastCGI 处理的每个阶段,调用该函数更新 worker 的计分板的数值。

  2. fpm_scoreboard_proc_acquire函数:获取统计单元,调用的函数是 fpm_scoreboard_proc_get,这里也用到了锁机制,但是跟 update 对应的锁不一样。

  3. fpm_scoreboard_proc_release 函数:与 acquire 对应,释放统计单元。

这 3 个函数如何使用呢?举个例子,在 FastCGI 读取 Header 阶段,调用函数 fpm_request_reading_headers:

void fpm_request_reading_headers() /* {{{ */
{
    struct fpm_scoreboard_proc_s *proc;
    //代码省略//
    //获取统计单元
    proc = fpm_scoreboard_proc_acquire(NULL, -1, 0);
    //代码省略//
    //修改统计单元信息
    proc->request_stage = FPM_REQUEST_READING_HEADERS;
    proc->tv = now;
    proc->accepted = now;
    proc->accepted_epoch = now_epoch;
#ifdef HAVE_TIMES
    proc->cpu_accepted = cpu;
#endif
    proc->requests++;
    proc->request_uri[0] = '\0';
    proc->request_method[0] = '\0';
    proc->script_filename[0] = '\0';
    proc->query_string[0] = '\0';
    proc->auth_user[0] = '\0';
    proc->content_length = 0;
    //释放统计单元
    fpm_scoreboard_proc_release(proc);
    /* idle--, active++, request++ */
    //更新计分板
    fpm_scoreboard_update(-1, 1, 0, 0, 1, 0, 0, FPM_SCOREBOARD_ACTION_INC, NULL);
}

从代码中可以看出,不同的阶段会分别调用这 3 个函数来更新计分,这样可以准确地获知 worker 的运行状态,如空闲数、请求执行时间等,提供了监控系统健康状态的手段,图7-12 就是基于计分板绘制的 PHP-FPM 空闲数的图。

了解了进程创建的过程、进程的管理,以及对 worker 进程的计分策略,下面我们具体分析一下 worker 进程是如何工作的。

网络编程

Socket创建

calling process 进程调用 fpm_init 中的 fpm_unix_init_main 函数 fork 出 master 进程,master 进程调用 fpm_sockets_init_main 函数进行网络的监听,其具体实现如下:

/* create all required sockets */
for (wp = fpm_worker_all_pools; wp; wp = wp->next) {
    switch (wp->listen_address_domain) {
    case FPM_AF_INET :
        wp->listening_socket = fpm_socket_af_inet_listening_socket(wp);
            break;

    case FPM_AF_UNIX :
        if (0 > fpm_unix_resolve_socket_premissions(wp)) {
            return -1;
        }
        wp->listening_socket = fpm_socket_af_unix_listening_socket(wp);
            break;
    }
    //代码省略//
}

从代码中可以看出,在 Linux 中,Nginx 服务器和 PHP-FPM 可以通过 TCP Socket 和 UNIX Socket 两种方式实现。其中,UNIX Socket 是一种终端,可以使同一台操作系统上的两个或多个进程进行数据通信。这种方式需要在 Nginx 配置文件中填写 PHP-FPM 的 pid 文件位置,效率要比 TCP Socket 高。TCP Socket 的优点是可以跨服务器,当 Nginx 和 PHP-FPM 不在同一台机器上时,只能使用这种方式。配置方式如下:

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; ;
    fastcgi_pass 127.0.0.1:9000; //TCP socket
    #fastcgi_pass unix:/var/run/php7-fpm.sock; //UNIX socket
    fastcgi_index index.php;
}

master 进程会创建 Socket,而 worker 进程会通过创建的 fd 来 accept 请求。

accept请求

根据上文的描述,FPM 的生命周期会进入循环中,代码如下:

zend_first_try {
        //循环开始
        while (EXPECTED(fcgi_accept_request(request) >= 0)) {//accept阻塞等待请求
        //代码省略//
        //初始化request
        init_request_info();

        fpm_request_info();

        /** 代码省略,主要处理40x响应 **/
        //解析FastCGI协议
        fpm_request_executing();
        //执行PHP脚本
        php_execute_script(&file_handle);

        fpm_request_end();
        fpm_log_write(NULL);

        php_request_shutdown((void *) 0);

        //超过最大执行次数,退出
        requests++;
        if (UNEXPECTED(max_requests && (requests == max_requests))) {
            cgi_finish_request(request, 1);
            break;
        }
        /* 循环结束 */
}

从代码中可以非常清晰地看出,worker 进程会进入循环,当没有请求时,会阻塞在 fcgi_accept_request,让出 CPU 资源,成为空闲进程,当请求到达时会有一个 worker 进程抢到并处理,进入 FasCGI 的处理阶段,下面通过对 FastCGI 协议的阐述来理解 FPM 的工作。

FastCGI协议

FastCGI 是一种协议,它是建立在 CGI/1.1 基础之上的,把 CGI/1.1 里面要传递的数据通过 FastCGI 协议定义的顺序和格式进行传递。为了更好地理解 FPM 的工作,下面具体阐述一下 FastCGI 协议的内容。

消息类型

FastCGI 协议分为 10 种类型,具体定义如下:

typedef enum _fcgi_request_type {
    FCGI_BEGIN_REQUEST  =  1, /* [in] */
    FCGI_ABORT_REQUEST  =  2, /* [in]  (not supported) */
    FCGI_END_REQUEST         =  3, /* [out] */
    FCGI_PARAMS       =  4, /* [in]  environment variables  */
    FCGI_STDIN        =  5, /* [in]  post data   */
    FCGI_STDOUT       =  6, /* [out] response   */
    FCGI_STDERR       =  7, /* [out] errors     */
    FCGI_DATA         =  8, /* [in]  filter data (not supported) */
    FCGI_GET_VALUES          =  9, /* [in]  */
    FCGI_GET_VALUES_RESULT = 10  /* [out] */
} fcgi_request_type;

整个 FastCGI 是二进制连续传递的,定义了一个统一结构的消息头,用来读取每个消息的消息体,方便消息包的切割。一般情况下,最先发送的是 FCGI_BEGIN_REQUEST 类型的消息,然后是 FCGI_PARAMS 和 FCGI_STDIN 类型的消息,当 FastCGI 响应处理完后,将发送 FCGI_STDOUT 和 FCGI_STDERR 类型的消息,最后以 FCGI_END_REQUEST 表示请求的结束。FCGI_BEGIN_REQUEST 和 FCGI_END_REQUEST 分别表示请求的开始和结束,与整个协议相关。

消息头

以上 10 种类型的消息都是以一个消息头开始的,其结构体定义如下:

typedef struct _fcgi_header {
    unsigned char version;
    unsigned char type;
    unsigned char requestIdB1;
    unsigned char requestIdB0;
    unsigned char contentLengthB1;
    unsigned char contentLengthB0;
    unsigned char paddingLength;
    unsigned char reserved;
} fcgi_header;

其中:

  1. version 标识 FastCGI 协议版本。

  2. type 标识 FastCGI 记录类型。

  3. requestId 标识消息所属的 FastCGI 请求,计算方式如下:

    (requestIdB1 << 8) + requestIdB0

    所以 requestId 的范围为 0~216-1,也就是 0~65535。

  4. contentLength 是标识消息的 contentData 组件的字节数,计算方式跟 requestId 类似,范围同样是 0~65535。

    (contentLengthB1 << 8) | contentLengthB0
  5. paddingLength 是标识消息的 paddingData 组件的字节数,范围是 0~255;协议通过 paddingData 提供给发送者填充发送的记录的功能,并且方便接受者通过 paddingLength 快速地跳过 paddingData。填充的目的是允许发送者更有效地处理保持对齐的数据。如果内容的长度超过 65535 字节怎么办?答案是可以分成多个消息发送。

FCGI_BEGIN_REQUEST

FCGI_BEGIN_REQUEST 的结构体定义如下:

typedef struct _fcgi_begin_request {
    unsigned char roleB1;
    unsigned char roleB0;
    unsigned char flags;
    unsigned char reserved[5];
} fcgi_begin_request;

其中,role 代表的是 Web 服务器期望应用扮演的角色,计算方式如下:

(roleB1 << 8) + roleB0

PHP 7 处理了 3 种角色,分别是 FCGI_RESPONDER、FCGI_AUTHORIZER 和 FCGI_FILTER。

flags 和 FCGI_KEEP_CONN 如果为 0,则在对本次请求响应后关闭连接;如果非 0,则在对本次请求响应后不会关闭连接。

名-值对

对于 type 为 FCGI_PARAMS 类型,FastCGI 协议提供了名-值对来很好地满足读写可变长度的 name 和 value,格式如下:

nameLength+valueLength+name+value

为了节省空间,对于 0~127 长度的值,Length 使用了一个 char 来表示,第一位为 0,对于大于 127 的长度的值,Length 使用了 4 个 char 来表示,第一位为 1。具体如图7-15所示。

image 2024 06 10 11 15 45 673
Figure 5. 图7-15 名和值长度示意图

长度计算代码如下:

if (UNEXPECTED(name_len >= 128)) {
    if (UNEXPECTED(p + 3 >= end)) return 0;
    name_len = ((name_len & 0x7f) << 24);
    name_len |= (*p++ << 16);
    name_len |= (*p++ << 8);
    name_len |= *p++;
}

这样可以表达 0~231 的长度。

请求协议

FastCGI 协议的定义结构体如下:

typedef struct _fcgi_begin_request_rec {
    fcgi_header hdr;
    fcgi_begin_request body;
} fcgi_begin_request_rec;

分析完 FastCGI 的协议,我们整体掌握了请求的 FastCGI 消息的内容,我们通过访问对应的接口,采用 gdb 抓取其中的内容。

首先我们修改 php-fpm.conf 的参数,保证只启动一个 worker:

pm.max_children = 1

重新启动 PHP-FPM:

./sbin/php-fpm -y etc/php-fpm.conf

对 worker 进行 gdb:

ps aux | grep php-fpm
root     30014  0.0  0.0142308  4724 ?         Ss   Nov26   0:03 php-fpm: master
    process (etc/php-fpm.conf)
chenlei   30015  0.0  0.0142508  5500 ?        S    Nov26   0:00 php-fpm: pool www
gdb -p 30015
(gdb) b fcgi_read_request

通过浏览器访问 Nginx, Nginx 转发到 PHP-FPM 的 worker 上,根据 gdb 可以输出 FastCGI 消息的内容:

(gdb) b fcgi_read_request

对于第一个消息,内容如图7-16所示。

image 2024 06 10 11 20 12 485
Figure 6. 图7-16 FCGI_BEGIN_REQUEST包头示意图

其中,type 对应的是 FCGI_BEGIN_REQUEST, requestid 为 1,长度为 8,恰好是 fcgi_begin_request 结构体的大小,内容如图7-17所示。

image 2024 06 10 11 20 53 267
Figure 7. 图7-17 FCGI_BEGIN_REQUEST示意图

role 对应的是 FCGI_RESPONDER。继续往下读,得到的消息内容如图7-18所示。

image 2024 06 10 11 21 31 386
Figure 8. 图7-18 FCGI_BEGIN_REQUEST包头示意图

其中,type 对应的是 FCGI_PARAMS, requestid 为 1,长度为

(contentLengthB1 << 8) | contentLengthB0  == 987

paddingLength=5,而 987+5=992,恰好是 8 的倍数。根据 contentLength+paddingLength 向后读取 992 长度的字节流,输出如下:

(gdb) p *p@987
$1 =  "\017TSCRIPT_FILENAME/home/xiaoju/webroot/beatles/application/mis/mis/src/index.
    php/admin/operation/index\f\016QUERY_STRINGactivity_id=89\016\003REQUEST_
    METHODGET\f\000CONTENT_TYPE\016\000CONTENT_LENGTH\v  SCRIPT_NAME/index.php/
    admin/operation/index\v%REQUEST_URI/admin/operation/index? activity_id=89\f
    DOCUMENT_URI/index.php/admin/operation/index\r4DOCUMENT_ROOT/home/xiaoju/
    webroot/beatles/application/mis/mis/src\017\bSERVER_PROTOCOLHTTP/1.1\021\
    aGATEWAY_INTERFACECGI/1.1\017\vSERVER_SOFTWAREnginx/1.2.5\v\rREMOTE_
    A D D R172.22.32.131\v\005R E M O T E_P O R T50973\v\f S E R V E R_A D D R10.94.98.116\
    v\004SERVER_PORT8085\v\000SERVER_NAME\017\003REDIRECT_STATUS200\t\021HTTP_
    HOST10.94.98.116:8085\017\nHTTP_CONNECTIONkeep-alive\017xHTTP_USER_
    AGENTMozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML,
      like Gecko) Chrome/62.0.3202.94 Safari/537.36\036\001HTTP_UPGRADE_INSECURE_
    REQUESTS1\vUHTTP_ACCEPTtext/html, application/xhtml+xml, application/
    xml; q=0.9, image/webp, image/apng, */*; q=0.8\024\rHTTP_ACCEPT_ENCODINGgzip,
    deflate\024\027HTTP_ACCEPT_LANGUAGEzh-CN, zh; q=0.9, en; q=0.8"

根据名-值对的长度规则,我们可以看出,FastCGI 协议封装了类似于 HTTP 协议的键-值对。读取完毕后,继续跟踪消息,输出可以得出如图7-19所示的消息。

image 2024 06 10 11 23 28 940
Figure 9. 图7-19 FCGI_BEGIN_REQUEST包头示意图

其中,type 对应的是 FCGI_PARAMS, requestid 为 1,长度为 0,此时完成了 FastCGI 协议消息的读取过程。下面介绍处理完请求后返回给 Nginx 的 FastCGI 协议的消息。

响应协议

fcgi_finish_request 调用 fcgi_flush, fcgi_flush 中封装一个 FCGI_END_REQUEST 消息体,再通过 safe_write 写入 Socket 连接的客户端描述符。

int fcgi_flush(fcgi_request *req, int close)
{
    int len;

    close_packet(req);
    len = (int)(req->out_pos - req->out_buf);

    if (close) {
        fcgi_end_request_rec *rec = (fcgi_end_request_rec*)(req->out_pos);
        //创建FCGI_END_REQUEST的头
        fcgi_make_header(&rec->hdr,  FCGI_END_REQUEST,  req->id,  sizeof(fcgi_end_
            request));
        //写入appStatus
        rec->body.appStatusB3 = 0;
        rec->body.appStatusB2 = 0;
        rec->body.appStatusB1 = 0;
        rec->body.appStatusB0 = 0;
        //修改protocolStatus为FCGI_REQUEST_COMPLETE;
        rec->body.protocolStatus = FCGI_REQUEST_COMPLETE;
        len += sizeof(fcgi_end_request_rec);
    }

    if (safe_write(req, req->out_buf, len) ! = len) {
        req->keep = 0;
        req->out_pos = req->out_buf;
        return 0;
    }

    req->out_pos = req->out_buf;
    return 1;
}

到此我们就完全掌握了 FastCGI 协议。整个 FPM 模式实际上是多进程模式,首先由 calling process 进程 fork 出 master 进程,master 进程会创建 Socket,然后 fork 出 worker 进程,worker 进程会在 accept 处阻塞等待,请求过来时,由其中一个 worker 进程处理,按照 FastCGI 模式进行各阶段的读取,然后解析 PHP 并执行,最后按照 FastCGI 协议返回数据,继续进入 accept 处阻塞等待。另外,FPM 建立了计分板机制,可以关注全局和每个 woker 的工作情况,方便使用者监控。

除了 CLI 模式和 FPM 模式,还有很多其他基于 SAPI 的模式,下面我们简单介绍下。