PHP处理相关细节 - FastCGI协议、FastCGI请求处理、ZendEngine、Opcode字节码

phpfpm在接收到fastcgi协议后或者直接通过php执行脚本,zend engine内部处理过程

1. ZendEngine

  • Zend引擎:充当PHP的核心和大脑
  • Zend引擎:是由Technion - Israel Institute of Technology(以色列)的两名学生开发的虚拟机。
  • Zend引擎由三个主要组件组成:
    • Lexical analyzer or the Lexer(词法分析) - 负责对脚本进行标记
    • Parser(解析器) - 解析令牌并生成操作码或opnums
    • Executor(执行程序) - 执行操作码。
  • Zend引擎包含以下内部组件:
    • ZFMI或Zend功能模块接口 - 不同模块之间的通信通道
    • 操作码缓存 - 缓存操作码,以便在需要时可以重复使用(Opcache模块支持)

(Web服务器以及PHP底层执行涉及模块)

  • PHP提供了请求处理和其他Web服务器的接口(SAPI)
  • Zend引擎是PHP实现的核心,提供了语言实现上的基础设施(词法、语法解析、扩展机制、内存管理
  • ZendAPI,目前PHP的实现和Zend引擎之间的关系非常紧密,甚至有些过于紧密了,PHP的扩展大都都采用ZendAPI耦合一起

参考:

2. SAPI

SAPI(Server Application Programming Interface)指的是PHP具体应用的编程接口,PHP脚本要执行有很多种方式,通过Web服务器,或者直接在命令行下,也可以嵌入在其他程序中,脚本执行的开始都是以SAPI接口实现开始的。

整个SAPI类似于模板模式,SAPI.cSAPI.h文件所包含的一些函数就是模板方法模式中的抽象模板;

  • 初始化全局、常量、ZendEngine核心、ini解析、
  • 模块初始化阶段(MINIT)
    • 模块激活阶段(RINIT)
    • 请求到达,处理开始
      • PHP初始化执行脚本的基本环境(包括保存PHP运行过程中变量名称和值内容的符号表, 以及当前所有的函数以及类等信息的符号表)
      • 调用所有模块的RINIT函数,完成请求
    • 模块关停节点(RSHUTDOWN)
  • 进入结束阶段(MSHUTDOWN)

2.1. 图示SAPI生命周期

(多进程SAPI)

(多线程SAPI)

2.2. Apache运行php

Apache以mod_php5模块的形式集成PHP,PHP模块的作用是接收Apache传递过来的PHP文件请求,并处理这些请求, 然后将处理后的结果返回给Apache。

  1. Apache启动之前,配置好PHP模块
  2. 启动阶段,获取系统资源,整个过程处于一个单进程单线程的环境中(包含httpd.conf解析、mod_php模块加载、系统资源初始化-日志、内存、共享段等)
  3. 运行阶段,Apache主要工作是处理用户的服务请求,基于安全,使用普通用户处理,处理过程包括连接、处理、断开3个大的阶段,细分为:
    • 读请求:Post-Read-Request、URI转义、Header 解析
    • 认证授权:Access Control、Authentication、Authorization
    • 内容:MIME Type Checking、FixUp、
    • 响应:Response
    • 清理:Logging、CleanUp
  4. Apache处理请求对象为request_rec,逻辑web服务器/虚拟机对象为server_rec,管理连接对象为conn_rec

3. FastCGI运行php

  • CGI为通用网关缩写,CGI描述了客户端和这个程序之间传输数据的一种标准,可以用于执行客服端发送给服务端应用程序的请求数据,
  • FastCGI是Web服务器和处理程序直接的协议,与语言无关,CGI的改进(常驻型CGI,解决重复加载的问题),CGI接受FastCGI进程管理器的调度

FastCGI 是与语言无关的、可伸缩架构的 CGI 开放扩展,将 CGI 解释器进程保持在内存中,以此获得较高的性能。 CGI 程序反复加载是 CGI 性能低下的主要原因,如果 CGI 程序保持在内存中并接受 FastCGI 进程管理器调度, 则可以提供良好的性能、伸缩性、Fail-Over 特性等。

FastCGI进程管理器自身初始化,启动多个CGI解释器进程,并等待来自 Web Server 的连接。Web服务器与FastCGI进程管理器进行 Socket 通信,通过FastCGI协议发送CGI环境变量和标准输入数据给CGI解释器进程。CGI解释器进程完成处理后将标准输出和错误信息从同一连接返回 Web Server。CGI 解释器进程接着等待并处理来自Web Server的下一个连接

通常的Nginx+PhpFPM就是通过Web服务器将数据基于fastCGI协议封装,通过Socket与FastCGI子进程(由FastCGI主进程管理器启动)交互,进行php的脚本执行。

PHP的CGI实现了FastCGI协议,通过绑定TCP或UDP协议的服务,接收来至Web服务器的请求,随后进行PHP的生命周期。

(web服务器与fastcgi进程管理器)

参考:

3.1. php-src/main/fastcgi.h

FastCGI协议:

  • 是FastCGI二进制协议,有统一结构的消息头
  • FastCGI请求时序:FCGI_BEGIN_REQUESTFCGI_PARAMSFCGI_STDINFCGI_STDOUTFCGI_STDERRFCGI_END_REQUEST
  • FastCGI协议头,包含协议版本、类型、请求ID、请求长度等信息
  • 协议请求状态:正常处理/过载拒绝/限流拒绝/未知拒绝
// fastcgi请求类型
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;

typedef enum _fcgi_protocol_status {
    FCGI_REQUEST_COMPLETE   = 0,
    FCGI_CANT_MPX_CONN      = 1,
    FCGI_OVERLOADED         = 2,
    FCGI_UNKNOWN_ROLE       = 3
} dcgi_protocol_status;

4. TCP为例,PHP中CGI的生命周期

  • 调用socket函数创建一个TCP用的流式套接字;
  • 调用bind函数将服务器的本地地址与前面创建的套接字绑定;
  • 调用listen函数将新创建的套接字作为监听,等待客户端发起的连接,当客户端有多个连接连接到这个套接字时,可能需要排队处理;
  • 服务器进程调用accept函数进入阻塞状态,直到有客户进程调用connect函数而建立起一个连接;
  • 当与客户端创建连接后,服务器调用read_stream函数读取客户的请求;
  • 处理完数据后,服务器调用write函数向客户端发送应答。

(TCP上处理Web服务器的请求事务的时序)

4.1. php-src/sapi/cgi/cgi_main.c

sapi中的cgi程序中的cgi_main.c的main函数中,会执行下述socket监听代码(通过调用php-src/main/fastcgi.c中的fcgi_listen函数实现)

// 调用fcgi_listen函数,获取fcgi套接字
if (bindpath) {
    fcgi_fd = fcgi_listen(bindpath, 128);   //  实现socket监听,调用fcgi_init初始化
    ...
}
...
// phpcgi通过fork启动子进程,
while (parent) {
do {
    pid = fork();   //  生成新的子进程
    switch (pid) {
    case 0: //  子进程
        parent = 0;
        /* don't catch our signals */
        sigaction(SIGTERM, &old_term, 0);   //  终止信号
        sigaction(SIGQUIT, &old_quit, 0);   //  终端退出符
        sigaction(SIGINT,  &old_int,  0);   //  终端中断符
        break;
        ...
        default:
        /* Fine */
        running++;
        break;
} while (parent && (running < children));

...
// CGI进程接收请求,会
while (!fastcgi || fcgi_accept_request(&request) >= 0) {
    SG(server_context) = (void *) &request;
    init_request_info(TSRMLS_C);
    CG(interactive) = 0;
    ...
}
...
// 不同的php模式行为,对应的php执行操作也不同
switch (behavior) {
    case PHP_MODE_STANDARD:
        php_execute_script(&file_handle);
        break;
    case PHP_MODE_LINT:
        PG(during_request_startup) = 0;
        exit_status = php_lint_script(&file_handle);
        ...
        break;
    case PHP_MODE_STRIP:
        if (open_file_for_scanning(&file_handle) == SUCCESS) {
            zend_strip();
            zend_file_handle_dtor(&file_handle);
            php_output_teardown();
        }
        return SUCCESS;
        break;
    case PHP_MODE_HIGHLIGHT:
        {
            zend_syntax_highlighter_ini syntax_highlighter_ini;

            if (open_file_for_scanning(&file_handle) == SUCCESS) {
                php_get_highlight_struct(&syntax_highlighter_ini);
                zend_highlight(&syntax_highlighter_ini);
                if (fastcgi) {
                    goto fastcgi_request_done;
                }
                zend_file_handle_dtor(&file_handle);
                php_output_teardown();
            }
            return SUCCESS;
        }
        break;
}
  • init_request_info函数,初始化fastcgi请求
  • php_request_startup函数,处理fastcgi请求
  • fcgi_finish_request函数,接收fastcgi请求
  • fcgi_destroy_request,请求销毁
  • php_module_shutdown,模块关闭
  • sapi_shutdown,sapi关闭

4.2. php-src/main/fastcgi.c

  • fcgi_listen函数
// 通过区分tcp和udp有不同的初始化处理
if ((s = strchr(path, ':'))) {
    port = atoi(s+1);
    if (port != 0 && (s-path) < MAXPATHLEN) {
        strncpy(host, path, s-path);
        host[s-path] = '\0';
        tcp = 1;
    }
} else if (is_port_number(path)) {
    port = atoi(path);
    if (port != 0) {
        host[0] = '\0';
        tcp = 1;
    }
}
... 
if (tcp) {
    memset(&sa.sa_inet, 0, sizeof(sa.sa_inet));
    sa.sa_inet.sin_family = AF_INET;
    ...
}else{
    memset(&sa.sa_unix, 0, sizeof(sa.sa_unix));
    sa.sa_unix.sun_family = AF_UNIX;
    ...
}
...
// 执行常规TCP流程三个阶段(socket创建、绑定、监听)
if ((listen_socket = socket(sa.sa.sa_family, SOCK_STREAM, 0)) < 0 ||
    #ifdef SO_REUSEADDR
    setsockopt(listen_socket, SOL_SOCKET, SO_REUSEADDR, (char*)&reuse, sizeof(reuse)) < 0 ||
    #endif
    bind(listen_socket, (struct sockaddr *) &sa, sock_len) < 0 ||
    listen(listen_socket, backlog) < 0) {
    close(listen_socket);
    fcgi_log(FCGI_ERROR, "Cannot bind/listen socket - [%d] %s.\n",errno, strerror(errno));
} 
...    
  • fcgi_accept_request函数,接收一个fastcgi请求处理:
int fcgi_accept_request(fcgi_request *req) {
    while (1) {
        if (req->fd < 0) {
            while (1) {
                ...
                req->fd = accept(listen_socket, (struct sockaddr *)&sa, &len);
                ...
            }
        }
    }
...
    // 读取fastcgi请求
    if (fcgi_read_request(req)) {
        return req->fd;
    } else {
        fcgi_close(req, 1, 1);
    }
...
  • fcgi_read_request函数,读取fastcgi相关信息:
// fastcgi请求类型为`FCGI_BEGIN_REQUEST`类型,从req中读取内容
if (hdr.type == FCGI_BEGIN_REQUEST && len == sizeof(fcgi_begin_request)) {
    fcgi_begin_request *b;

    if (safe_read(req, buf, len+padding) != len+padding) {
        return 0;
    }
    ...
}
...

4.3. php-src/main/main.c

CGI通过接收fastcgi请求,进行相关初始化后,若为PHP_MODE_STANDARD,会调用php_execute_script(zend_file_handle *primary_file)函数

// 在php_execute_script执行过程中,基本都是调用ZendAPI相关的API方法
...
if (CG(skip_shebang) && prepend_file_p) {
    CG(skip_shebang) = 0;
    if (zend_execute_scripts(ZEND_REQUIRE, NULL, 1, prepend_file_p) == SUCCESS) {
        CG(skip_shebang) = 1;
        retval = (zend_execute_scripts(ZEND_REQUIRE, NULL, 2, primary_file, append_file_p) == SUCCESS);
    }
} else {
    retval = (zend_execute_scripts(ZEND_REQUIRE, NULL, 3, prepend_file_p, primary_file, append_file_p) == SUCCESS);
}
...

5. PHP脚本执行

PHP的SAPI,SAPI处于PHP整个架构较上层,而真正脚本的执行主要由Zend引擎来完成,编程语言分为编译或者解释性语言,php属于解释性语言,需要通过解释器将php源码,编译成指定的opcode码执行,如果装了opcode缓存扩展(如APC, xcache, eAccelerator等),该编译环境可以省略,直接进入opcode码执行阶段,避免每次运行重新进行编译所带来的性能损失。

分析和执行的过程需要经过:

  1. 读取到脚本文件后首先对代码进行词法分析(lex),将源代码(*.php文件内容)按照词法规则切分一个一个的标记(token),ps:切好的内容可以通过PHP中提供函数token_get_all()查看
  2. 使用语法分析器(PHP使用bison生成语法分析器),将代码编译为opcode
  3. Zend引擎会执行这些opcode,在执行opcode的过程中还有可能会继续重复进行编译-执行(比如执行eval,include/require等语句)

如果想直接查看生成的Opcode,可以使用php的vld扩展查看。

5.1. php-src/Zend/zend.c

再到zend_execute_scripts函数,就转到Zend引擎内部执行

// zend引擎执行API脚本
ZEND_API int zend_execute_scripts(int type, zval *retval, int file_count, ...) 
{
    va_list files;
    int i;
    zend_file_handle *file_handle;
    zend_op_array *op_array;

    va_start(files, file_count);
    for (i = 0; i < file_count; i++) {
        file_handle = va_arg(files, zend_file_handle *);
        if (!file_handle) {
            continue;
        }

        // zend 编译成
        op_array = zend_compile_file(file_handle, type);
        if (file_handle->opened_path) {
            zend_hash_add_empty_element(&EG(included_files), file_handle->opened_path);
        }
        zend_destroy_file_handle(file_handle);
        if (op_array) {
            // zend 执行
            zend_execute(op_array, retval);
            zend_exception_restore();
            if (UNEXPECTED(EG(exception))) {
                if (Z_TYPE(EG(user_exception_handler)) != IS_UNDEF) {
                    zend_user_exception_handler();
                }
                if (EG(exception)) {
                    zend_exception_error(EG(exception), E_ERROR);
                }
            }
            destroy_op_array(op_array);
            efree_size(op_array, sizeof(zend_op_array));
        } else if (type==ZEND_REQUIRE) {
            va_end(files);
            return FAILURE;
        }
    }
    va_end(files);

    return SUCCESS;
}

6. Opcode - 字节码

ZendEngine做到事情:

  1. Scanning(Lexing) ,将PHP代码转换为语言片段(Tokens)
  2. Parsing, 将Tokens转换成简单而有意义的表达式
  3. Compilation, 将表达式编译成Opocdes
  4. Execution, 顺次执行Opcodes,每次一条,从而实现PHP脚本的功能。

(PHP脚本执行过程)

opcode是计算机指令中的一部分,用于指定要执行的操作, 指令的格式和规范由处理器的指令规范指定。

除了指令本身以外通常还有指令所需要的操作数,可能有的指令不需要显式的操作数。 这些操作数可能是寄存器中的值,堆栈中的值,某块内存的值或者IO端口中的值等等。

通常opcode还有另一种称谓: 字节码(byte codes)。 例如Java虚拟机(JVM),.NET的通用中间语言(CIL: Common Intermeditate Language)等等。

PHP是构建在Zend虚拟机(Zend VM)之上的,PHP的opcode就是Zend虚拟机中的指令。

6.1. opcode操作指令

和CPU的机器指令类似,有一个标示指令的opcode字段,以及这个opcode所操作的操作数,PHP不像汇编那么底层, 在脚本实际执行的时候可能还需要其他更多的信息,extended_value字段就保存了这类信息, 其中的result域则是保存该指令执行完成后的结果。

每条opcode都有一个opcode_handler_t的函数指针字段,用于执行该opcode。

PHP有三种方式来进行opcode的处理:CALL,SWITCH和GOTO,PHP默认使用CALL的方式,也就是函数调用的方式, 由于opcode执行是每个PHP程序频繁需要进行的操作,可以使用SWITCH或者GOTO的方式来分发, 通常GOTO的效率相对会高一些,不过效率是否提高依赖于不同的CPU。

// opcode操作指令
struct _zend_op {
    opcode_handler_t handler; // 执行该opcode时调用的处理函数
    znode result;
    znode op1;
    znode op2;
    ulong extended_value;
    uint lineno;
    zend_uchar opcode;  // opcode代码
};

7. PhpFPM Model分析

7.1. main.c

FPM启动函数在fpm_main.c中

int php_request_startup(void)
void php_request_shutdown(void *dummy)

7.2. fpm_worker_pool.h

fpm进程池,进程池监听一个端口,进程池之间使用链表关联,有监听的套接字、uid、gid、日志事件等

// phpfpm进程池
struct fpm_worker_pool_s {
    struct fpm_worker_pool_s *next;
    struct fpm_worker_pool_config_s *config;
    char *user, *home;                                  /* for setting env USER and HOME */
    enum fpm_address_domain listen_address_domain;
    int listening_socket;
    int set_uid, set_gid;                               /* config uid and gid */
    int socket_uid, socket_gid, socket_mode;

    /* runtime */
    struct u *children;
    int running_children;
    int idle_spawn_rate;
    int warn_max_children;
    struct fpm_scoreboard_s *scoreboard;
    int log_fd;
    char **limit_extensions;
    /* for ondemand PM */
    struct fpm_event_s *ondemand_event;
    int socket_event_set;
};

7.3. fpm_children.h

进程池中的fpm子进程,有pid、自己归属pool等,fpm事件,双向链表

// 子进程创建
int fpm_children_make(struct fpm_worker_pool_s *wp, int in_event_loop, int nb_to_spawn, int is_debug);

// phpfpm进程池中子进程
struct fpm_child_s {
    struct fpm_child_s *prev, *next;
    struct timeval started;
    struct fpm_worker_pool_s *wp;
    struct fpm_event_s ev_stdout, ev_stderr;
    int shm_slot_i;
    int fd_stdout, fd_stderr;
    void (*tracer)(struct fpm_child_s *);
    struct timeval slow_logged;
    int idle_kill;
    pid_t pid;
    int scoreboard_i;
    struct zlog_stream *log_stream;
};

7.4. phpfpm 池

8. SO_REUSEADDR 和 SO_REUSEPORT

可以通过 man 7 socket查看到这两个参数的含义,另外可以参考man tcptcp_tw_reuse的含义

tcp_tw_reuse:是否允许重用TIME_WAIT套接字,以便在从协议的角度来看是安全的情况下进行新的连接(不推荐更改)

8.1. SO_REUSEADDR

bind的套接字是否可以重用本地地址

解决的问题:

  • 解决EADDRINUSE的问题
  • 解决TCP服务异常中断,如果监听的ip:port存在主动断开,在服务端会形成一个TIME_WAIT状态,默认会持续2MSL,若不启用地址重用,则服务无法在同一个ip:port上启用
// 设置套接字的option属性可得
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,(const void *)&reuse , sizeof(int));

8.2. SO_REUSEPORT

常规情况一个监听的套接字ip:port,多进程或多线程无法在同一ip:port套接字上进程listen监听,通过SO_REUSEPORT可以做到在多进程或多线程服务器下,每个进程或线程有一个独立的套接字监听socket,独立的listen和accept,但绑定相同的ip:port,提高程序的并发能力。

SO_REUSEPORT是linux kernel 3.9 引入的,若是多进程套接字同端口监听,需要所有监听的进程都要设置(线程可以做到资源共享),另外基于安全考虑,进程的有效用户ID需要保证一致。

解决的问题:

  • 通过每个进程或线程维护自己的套接字,可以避免惊群效应(多个线程从同一套接字竞争接收请求)
  • 通过内核层面的负载均衡,可以做到每个进程或线程都是有自己独立的套接字,接收均衡的连接请求()
// 设置套接字的option属性可得
setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT,(const void *)&reuse , sizeof(int));

8.3. 惊群问题

惊群问题是计算机科学中,当许多进程等待一个事件,事件发生后这些进程被唤醒,但只有一个进程能获得CPU执行权,其他进程又得被阻塞,这造成了严重的系统上下文切换代价。

解决问题方式,基于IO多路复用技术:

  • 不把所有进程都唤醒,仅wakeup一个进程/线程
  • 尽量避免上下文的开销(绑定CPU处理机,CPU亲缘性)

参考:

8.4. IO多路复用技术

可以参考man 7 epollman 2 poll

复用技术最开始在电信和计算机网络中,主要分享稀缺资源,有对应的MUX和DEMUX/DMX。IDMUX还可实现数据分解成多个流在不同信道上传输,并重建它们。

通信领域,有涉及通信复用的技术:空分复用、频分复用、时分复用、码分复用,以及偏振分割多路复用和轨道角动量多路复用

在计算中,I/O multiplexing,也可以指代通过单一循环事件(event loop)处理多I/O event,比如poll和select

poll,是在一个文件描述符上等待一个IO事件的发生,在文件符过多时候(通常通过数组存放被监控的描述符),系统调度的开销,对系统性能损耗较大 epoll,是一个内核调用的IO事件通知机制,相比poll机制,在对大量文件描述符监控下的IO事件,epoll可以做到O(1)的时间效率,而poll需要O(n)。epoll支持edge-triggered(ET)和level-triggered(LT)两种触发方式

参考:

9. 后续

暂时先写这么多,还有很多细节后续需要完善,后续有时间再把这个坑填上。

9.1. 补充:php7的zval调整

  • php5中的zval包含值、类型、引用计数器等,opcache jit时候效率低,转而优化该zval值
  • php5中的zval问题:
    • zval是php engine2使用,zval.value联合体中zend_object_value可以优化掉(基于引用)
    • zval未保留预定变量,在一些特点情况下导致需要额外能力来扩展zval
    • zval除对象和资源外,都是按值传递,导致ref_count统计还需要全局引用计数器
    • 写时分离,带来可能的性能问题
    • zval含引用计数器,copy字符串类型也是需要值拷贝
    • MAKE_STD_ZVAL/ALLOC_ZVAL内存申请分配带来性能问题
  • php7中的zval:
    • 全部改为联合体(value、u1-type、u2-辅助扩展部分)
    • zval变成了一个值指针,它要么保存着原始值, 要么保存着指向一个保存原始值的指针
    • zval中的value可以保存下的值,直接赋值,不进行引用计数,省掉一次指针解引用

php7性能提升,主要是基于对内存的优化:持续不断的降低内存占用提高缓存友好性降低执行的指令数3个方面带来优化效果

参考: