第14章 扩展

565 阅读12分钟

经过前面几章的学习,我们对PHP的整个底层实现、执行有了深入的认识,本章我们来学习下扩展相关内容。我们在日常的PHP开发中,已经不知不觉使用了很多的扩展,如常见的时间格式化、JSON编码和解码、数组操作、数据库操作等,这些都是通过扩展来实现的。PHP的扩展按照归属可以分为四大类,分别如下:

  • 核心扩展库:其实属于PHP内核的一部分,这些扩展不能通过编译选项排除掉,如array系列、date系列、string系列、反射等。
  • 绑定的扩展库:常见的iconv、ftp、gd、json等都属于绑定的扩展库。
  • 外部扩展库:常见的curl、dom、mysqli等都属于绑定的外部扩展库,这些扩展库已经包含在PHP源码中但是需要编译它们才能使用,并且可能需要额外的扩展库。
  • PECL扩展库:常见的有Memcached、Yaf、Xhprof等,这些扩展来自PECL,它们可能需要额外的库。

PHP的扩展还可以分为两种,一种是PHP扩展,另外一种是Zend扩展。在php_module_start加载扩展时,通过extension=.so加载的扩展称为PHP扩展;通过zend_extension=.so加载的扩展称为Zend扩展。在某些文档中,根据扩展在PHP源码中的定义,把PHP扩展称为模块(PHP源码中有module关键字),把Zend扩展称为扩展(源码中有extension关键字)。

除了加载方式不同,两种扩展最根本的区别在于实现的不同。PHP扩展需要实现一个名为zend_module_entry的结构体,而Zend扩展需要实现一个名为zend_extension的结构体。扩展xdebug既实现了zend_module_entry,也实现了zend_extension,所以说其实这个扩展有点怪,既是PHP扩展,也是Zend扩展。

至于加载顺序的不同,我们后面会介绍。现在我们先来介绍扩展的实现原理。

14.1 扩展的实现原理

无论是PHP扩展还是Zend扩展,它们实现的基本原理都是开发者按照扩展规范和API,实现自己的功能,然后要么以静态编译方式编译到PHP的可执行文件,要么以动态编译方式生成动态链接库.so文件。加载扩展时,PHP将动态链接库文件加载到内存,校验其符合规范后,PHP即可以使用此扩展。

那么这里提到的库是什么呢?库可以看作可复用代码的二进制形式。在计算机世界中,每个程序都要依赖很多基础的底层库。库分为两种:静态链接库和动态链接库。在编译生成可执行程序时,一起被打包到可执行文件的库称为静态链接库,Linux下一般以.a为扩展名(Windows下为.lib);而生成可执行文件时并未被打包,在运行时才被载入的库,称为动态链接库,Linux下一般以.so为扩展名(Windows下为.dll)。动态链接库使用起来比静态链接库稍微麻烦,但有着非常明显的优势。

  1. 相对于静态链接库,使用动态链接库可以有效地缩小程序体积,节省空间,在同一个运行环境下,不同的程序可以调用相同的库。
  2. 程序更新时,使用了静态链接库的程序需要重新编译整个程序,用户也需要重新下载安装完整的程序,而使用了动态链接库的程序可以只更新库,实现增量更新。
  3. 有助于节省内存。当我们需要某个扩展时,才将其加载到内存中。
  4. 有助于资源共享。这里讲的资源共享,是指在多个进程中实现共享。下面我们来实现一个简单的动态链接库libhelloworld.so并调用:
#include <stdio.h>
void helloworld()
{
    printf("hello, world! \n");
}

代码很简单,只有一个函数——helloworld,调用这个函数,会输出字符串“hello,world!”。我们将其编译生成动态链接库:

$ gcc -shared -fPIC  -o libhelloworld.so helloworld.c
$ ll
-rw-r--r--1 vagrant vagrant   71 1月  25 14:15 helloworld.c
-rwxr-xr-x 1 vagrant vagrant 7.9K 1月  25 14:30 libhelloworld.so

现在我们显式加载此动态链接库:

#include <stdio.h>
#include <dlfcn.h>

int main(int argc, char* argv[])
{
    void* handle = dlopen("./libhelloworld.so", RTLD_LAZY);
    char* error = dlerror();
    if(! handle || error)
    {
        printf("load so error! \n");
        return 1;
    }
    void (*func)() = dlsym(handle, "helloworld");
    if(! func)
    {
        printf("load func error! \n");
        dlclose(handle);
        return 1;
    }
    func();
    dlclose(handle);
    return 0;
}

编译、链接此代码生成可执行程序:

$ gcc test.c -L. -lhelloworld -ldl
$ ll
-rwxr-xr-x 1 vagrant vagrant 8.6K 1月  25 14:34 a.out
-rw-r--r--1 vagrant vagrant   71 1月  25 14:15 helloworld.c
-rwxr-xr-x 1 vagrant vagrant 7.9K 1月  25 14:30 libhelloworld.so
-rw-r--r--1 vagrant vagrant  459 1月  25 14:34 test.c
$ ./a.out
Hello, world!

可以看到已成功生成可执行文件a.out,运行后输出字符串“hello, world!”。由此可见,动态链接库libhelloworld.so中的函数执行成功。

仔细研究下这段代码,会发现它一共使用了4个函数来加载动态链接库并执行其中的函数,它们分别是dlopen、dlerror、dlsym、dlclose,分别用来加载动态链接库、获得相关错误信息、获得函数地址、关闭动态链接库。

PHP的扩展实现原理和这段代码极其相似,也是用这4个函数完成了扩展的加载和函数的调用。当在我们在PHP程序中动态通过dl函数或通过php.ini来动态加载扩展时,PHP会从extension_dir配置项指定的目录加载扩展,这个目录默认为/lib/php/extensions/--ZEND_MODULE_API_NO,如/usr/local/php/lib/php/extensions/debug-non-zts-20160303或/usr/local/php/lib/php/extensions/no-debug-zts-20160303。

14.2 PHP扩展

14.2.1 扩展的实现

前面已经讲了,PHP扩展需要实现一个结构体——zend_module_entry,现在我们来研究下这个结构体。

struct _zend_module_entry {
    unsigned short size;
    unsigned int zend_api;
    unsigned char zend_debug;
    unsigned char zts;
    const struct _zend_ini_entryini_entry;
    const struct _zend_module_depdeps;
    const char *name;
    const struct _zend_function_entryfunctions;
    int (*module_startup_func)(INIT_FUNC_ARGS);
    int (*module_shutdown_func)(SHUTDOWN_FUNC_ARGS);
    int (*request_startup_func)(INIT_FUNC_ARGS);
    int (*request_shutdown_func)(SHUTDOWN_FUNC_ARGS);
    void (*info_func)(ZEND_MODULE_INFO_FUNC_ARGS);
    const char *version;
    size_t globals_size;
    void* globals_ptr;
    void (*globals_ctor)(void *global);
    void (*globals_dtor)(void *global);
    int (*post_deactivate_func)(void);
    int module_started;
    unsigned char type;
    void *handle;
    int module_number;
    const char *build_id;
};

主要字段说明如下。

  • size:此结构体的大小,即sizeof(struct _zend_module_entry)。
  • zend_api:PHP扩展版本号。
  • zend_debug:是否开启debug模式。
  • zts:线程是否安全。
  • ini_entry:php.ini相关,详情请看第8章。
  • name:扩展名称,不得与其他扩展名相同。
  • functions:扩展提供的内部函数数组。
  • module_startup_func:模块初始化阶段回调的钩子函数。
  • module_shutdown_func:模块关闭阶段回调的钩子函数。
  • request_startup_func:请求初始化阶段回调的钩子函数。
  • request_shutdown_func:请求关闭阶段回调的钩子函数。
  • info_func:被php_info函数调用的展示扩展信息的函数。
  • version:扩展的版本号。

如果当前扩展是以动态链接库的方式来使用的,那么扩展除了要实现了struct_zend_module_entry结构外,还需要实现get_module函数来让内核获得当前扩展结构。此函数的实现有统一的规范,通过宏来实现:

#define ZEND_GET_MODULE(name) \
    BEGIN_EXTERN_C()\
    ZEND_DLEXPORT  zend_module_entry  *get_module(void)  {  return  &name##_module_
        entry; }\
    END_EXTERN_C()

由此可以看出,实现的函数名为get_module,如果此扩展名为***,那么定义扩展的变量名必须为***_module_entry。例如,JSON扩展为json_module_entry。

从8.4节中知道,解析php.ini时,PHP扩展和Zend扩展相关配置都会被加载到全局变量extension_lists中,PHP扩展保存在extension_lists.functions中,而Zend扩展保存在extension_lists.engine中。

在PHP_MINIT阶段,通过php_ini_register_extensions函数完成扩展的注册:

void php_ini_register_extensions(void)
{
    // Zend扩展
    zend_llist_apply(&extension_lists.engine, php_load_zend_extension_cb);
    // PHP扩展
    zend_llist_apply(&extension_lists.functions, php_load_php_extension_cb);

    zend_llist_destroy(&extension_lists.engine);
    zend_llist_destroy(&extension_lists.functions);
}

在这个函数中,依次遍历extension_lists.engine和extension_lists.functions,调用php_load_php_extension_cb函数完成对PHP扩展的加载,调用php_load_zend_extension_cb函数完成对Zend扩展的加载:

static void php_load_php_extension_cb(void *arg)
{
#ifdef HAVE_LIBDL
    php_load_extension(*((char **) arg), MODULE_PERSISTENT, 0);
#endif
}

由源码可知,PHP扩展加载的关键步骤在php_load_extension中完成,现在仔细研究下这个函数:

  1. 获取配置文件定义的扩展目录extension_dir。
  2. 调用dlopen打开对应的动态链接库.so文件。
  3. 调用dlsym找到get_module函数的地址,通过get_module方法获得此扩展的struct_zend_module_entry结构。
  4. zend api版本校验。
  5. 检查扩展依赖,没有错误的时候将扩展添加到全局变量module_registry中;如果存在内部函数,则将这些函数注册到全局变量EG(function_table)中。

至此完成了PHP扩展的加载。

14.2.2 JSON扩展

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,易于人阅读和编写,同时也易于机器解析和生成。JSON采用完全独立于语言的文本格式,各类语言都有对其实现,包括C、C++、C#、Java、JavaScript、Perl、Python等,因此JSON格式是比较理想的数据交换语言。

对象是一个无序的键/值对集合。一个对象以“{”(左括号)开始,从“}”(右括号)结束。每个“名称”后跟一个“:”(冒号),名称/值对之间使用“,”(逗号)分隔。

数组是值(value)的有序集合。一个数组以“[”(左中括号)开始,以“]”(右中括号)结束。值之间使用“,”(逗号)分隔,值可以是双引号括起来的字符串(string)、数值(number)、true、false、null、对象(object)或者数组(array)。这些结构可以嵌套。

字符串(string)是由双引号括起来的任意数量unicode字符的集合,使用“\”(反斜杠)转义。一个字符(character)即一个单独的字符串(character string)。

除去未曾使用的八进制与十六进制格式,数值(number)与C或者Java语言的数值非常相似。我们来看下PHP中的JSON扩展,其扩展定义如下:

zend_module_entry json_module_entry = {
    STANDARD_MODULE_HEADER,
    "json",
    json_functions,
    PHP_MINIT(json),
    NULL,
    NULL,
    NULL,
    PHP_MINFO(json),
    PHP_JSON_VERSION,
    PHP_MODULE_GLOBALS(json),
    PHP_GINIT(json),
    NULL,
    NULL,
    STANDARD_MODULE_PROPERTIES_EX
};

此结构体的前6个字段是用宏STANDARD_MODULE_HEADER来定义的。多读几个PHP自带的PHP扩展源码,会发现绝大部分扩展用这个宏来定义前6个字段,实际结构如下:

sizeof(zend_module_entry), ZEND_MODULE_API_NO, ZEND_DEBUG, USING_ZTS, NULL, NULL

当前JSON扩展实际展开后如下(注意,部分参数根据实际的编译参数可能有所不同,如是否开启debug):

sizeof(zend_module_entry), 20160303, 0, 0, NULL, NULL

STANDARD_MODULE_HEADER之后是扩展名“json”,然后是扩展JOSN提供的内部函数数组json_functions:

static const zend_function_entry json_functions[] = {
    PHP_FE(json_encode, arginfo_json_encode)
    PHP_FE(json_decode, arginfo_json_decode)
    PHP_FE(json_last_error, arginfo_json_last_error)
    PHP_FE(json_last_error_msg, arginfo_json_last_error_msg)
    PHP_FE_END
};

可以看出,JOSN扩展提供了4个内部函数,其各自实现不在此书的讨论范围之内。

结构体的后面是扩展JSON提供的模块初始化阶段回调的钩子函数PHP_MINFO_FUNCTION(json) 以及扩展版本等相关信息。

14.3 Zend扩展

14.3.1 Zend扩展的实现

Zend扩展必须要实现的结构体为zend_extension:

struct _zend_extension {
    char *name;
    char *version;
    char *author;
    char *URL;
    char *copyright;

    startup_func_t startup;
    shutdown_func_t shutdown;
    activate_func_t activate;
    deactivate_func_t deactivate;

    message_handler_func_t message_handler;

    op_array_handler_func_t op_array_handler;

    statement_handler_func_t statement_handler;
    fcall_begin_handler_func_t fcall_begin_handler;
    fcall_end_handler_func_t fcall_end_handler;

    op_array_ctor_func_t op_array_ctor;
    op_array_dtor_func_t op_array_dtor;
    int (*api_no_check)(int api_no);
    int (*build_id_check)(const char* build_id);
    op_array_persist_calc_func_t op_array_persist_calc;
    op_array_persist_func_t op_array_persist;
    void *reserved5;
    void *reserved6;
    void *reserved7;
    void *reserved8;

    DL_HANDLE handle;
    int resource_number;
};

主要字段说明如下。

  • name:扩展名称。
  • version:扩展版本号。
  • author:作者。
  • URL:扩展官方主页。
  • copyright:版本声明。
  • startup:扩展启动钩子函数。
  • shutdown:扩展关闭钩子函数。
  • activate:请求初始化阶段钩子函数。
  • deactivate:请求结束阶段钩子函数。

前文我们提到解析php.ini时会把Zend扩展保存在extension_lists.engine中,在通过php_ini_register_extensions注册扩展时会先调用php_load_zend_extension_cb完成对Zend扩展的加载:

static void php_load_zend_extension_cb(void *arg)
{
    ……
    if (IS_ABSOLUTE_PATH(filename, length)) {
        zend_load_extension(filename);
    } else {
        ……
        zend_load_extension(libpath);
    }
}

具体的加载流程是在zend_load_extension函数中实现的,其实现和PHP扩展的加载php_load_extension大同小异,不同之处是,内核最终将Zend扩展加载到全局变量zend_extensions中。

14.3.2 opcache扩展

通过对前几章的学习,我们了解了PHP的执行原理,简单地讲,就是PHP内核把PHP代码编译成opcode,然后再来执行opcode。这个过程看上去挺简单,没有可以优化的地方,其实不然。在PHP代码没有变化的情况下,内核生成的opcode是不会变的,那么我们可以把生成的opcode缓存到文件或内存中,这样可以避免重复执行生成opcode的过程,提高PHP的运行效率。

有人会想,既然如此,为何PHP不能像Java一样,把编译生成的opcode部署到线上,让PHP直接执行opcode,这样就不存在刚才说的重复执行生成opcode的过程了。这的确是一个好办法,不过PHP虽然是跨平台的,但是与Java的跨平台不同,PHP是语言跨平台,生成的opcode并不能跨平台执行,而Java生成的bytecode是可跨平台执行的,另外热部署是PHP的优势之一,直接部署opcode有悖于这一特性。所以PHP开发者最终选择了使用缓存opcode这一功能来优化PHP的执行。

现在我们来深入了解下opcache的运行原理,先来研究下此扩展实现的zend_extension结构。

ZEND_EXT_API zend_extension zend_extension_entry = {
    ACCELERATOR_PRODUCT_NAME,                 /* name */
    PHP_VERSION,                              /* version */
    "Zend Technologies",                      /* author */
    "http://www.zend.com/",                   /* URL */
    "Copyright (c) 1999-2017",                /* copyright */
    accel_startup,                            /* startup */
    NULL,                                     /* shutdown */
    accel_activate,                           /* per-script activation */
    accel_deactivate,                         /* per-script deactivation */
    NULL,                                     /* message handler */
    NULL,                                     /* op_array handler */
    NULL,                                     /* extended statement handler */
    NULL,                                     /* extended fcall begin handler */
    NULL,                                     /* extended fcall end handler */
    NULL,                                     /* op_array ctor */
    NULL,                                     /* op_array dtor */
    STANDARD_ZEND_EXTENSION_PROPERTIES
};

opcache的module startup方法为accel_startup。下面我们研究下accel_startup方法中的操作。

static int accel_startup(zend_extension *extension){
    ……
    accel_globals_ctor(&accel_globals);
    ……
    if (start_accel_module() == FAILURE) {
    }
    if (accel_find_sapi() == FAILURE) {
        ……
    }
    switch (zend_shared_alloc_startup(ZCG(accel_directives).memory_consumption))
    {
        case ALLOC_SUCCESS:
              zend_accel_init_shm();
              break;
        case ALLOC_FAILURE:
              ……
        case SUCCESSFULLY_REATTACHED:
              ……
        case FAILED_REATTACHED:
              ……

    }
    ……
}

主要操作步骤如下。

  1. 初始化一个全局变量accel_globals。
  2. 注册内部模块accel。
  3. 校验opcache是否支持当前的sapi。
  4. 分配并初始化共享内存。
  5. 初始化全局变量accel_shared_globals指向的结构体zend_accel_shared_globals。
  6. hook相关函数。

我们分别看下每一步都具体做了些什么。

第一步,初始化一个全局变量accel_globals,将其置为0,全局变量accel_globals类型为zend_accel_globals,其结构如下:

typedef struct _zend_accel_globals {
    /* copy of CG(function_table) used for compilation scripts into cache */
    /* initially it contains only internal functions */
    HashTable      function_table;
    int            internal_functions_count;
    int            counted;   /* the process uses shared memory */
    zend_bool      enabled;
    zend_bool      locked;    /* thread obtained exclusive lock */
    HashTable      bind_hash; /* prototype and zval lookup table */
    zend_accel_directives   accel_directives;
    zend_string    *cwd;      /* current working directory or NULL */
    zend_string    *include_path; /* current value of "include_path" directive */
    char           include_path_key[32]; /* key of current "include_path" */
    char           cwd_key[32];          /* key of current working directory */
    int            include_path_key_len;
    int            include_path_check;
    int            cwd_key_len;
    int            cwd_check;
    int            auto_globals_mask;
    time_t         request_time;
    time_t         last_restart_time; /* used to synchronize SHM and in-process
        caches */
    char           system_id[32];
    HashTable      xlat_table;
#ifndef ZEND_WIN32
    zend_ulong     root_hash;
#endif
    /* preallocated shared-memory block to save current script */
    void                    *mem;
    void                    *arena_mem;
    zend_persistent_script *current_persistent_script;
    /* cache to save hash lookup on the same INCLUDE opcode */
    const zend_op          *cache_opline;
    zend_persistent_script *cache_persistent_script;
    /* preallocated buffer for keys */
    int                      key_len;
    char                     key[MAXPATHLEN * 8];
} zend_accel_globals;

主要字段说明如下。

  • function_table:注释很清晰,即把CG(function_table)复制到这,初始化时只把内部函数复制到这。

  • internal_functions_count:内部函数的个数。

  • counted:进程使用的共享内存大小。

  • enabled:Opcahce是否可用。

  • locked:线程是否获得了互斥锁。

  • bind_hash:HashTable,初始化时分配了10个元素。

  • accel_directives:opcache相关的配置,在函数opcache_get_configuration中完成初始化,主要存储php.ini中关于opcache的配置信息。

  • cwd:进程当前的工作路径。

  • include_path:当前文件的include_path。

  • auto_globals_mask:每次请求,都会在函数accel_activate中初始化为0,用来标记当次请求使用的全局变量。全局变量有以下4种。

    static const struct jit_auto_global_info
    {
        const char *name;
        size_t len;
    } jit_auto_globals_info[] = {
        { "_SERVER",  sizeof("_SERVER")-1},
        { "_ENV",     sizeof("_ENV")-1},
        { "_REQUEST", sizeof("_REQUEST")-1},
        { "GLOBALS",  sizeof("GLOBALS")-1},
    };
    
  • request_time:每次请求,都会在函数accel_activate中将其初始化为(time_t)sapi_get_request_time,记录此次请求的开始时间。

  • last_restart_time:在扩展初始化,或每次请求的初始化中进行判断,如果不等于共享内存中的last_restart_time,会将其置为共享内存中的last_restart_time。

  • system_id:存储一串特殊字符串的md5值,与缓存中的md5值进行比较,如果不同,说明有异常。

  • current_persistent_script:指向此次从cache获得的相关信息,指向一个结构体zend_persistent_script。

zend_persistent_script结构体如下:

typedef struct _zend_persistent_script {
    zend_script    script;
    zend_long      compiler_halt_offset;   /* position of __HALT_COMPILER or -1 */
    int             ping_auto_globals_mask; /* which autoglobals are used by the
        script */
    accel_time_t   timestamp;               /* the script modification time */
    zend_bool      corrupted;
    zend_bool      is_phar;

    void           *mem;                      /* shared memory area used by script
        structures */
    size_t         size;                    /* size of used shared memory */
    void          *arena_mem;               /* part that should be copied into
        process */
    size_t         arena_size;

    /* All entries that shouldn't be counted in the ADLER32
      * checksum must be declared in this struct
      */
    struct zend_persistent_script_dynamic_members {
        time_t       last_used;
        zend_ulong        hits;
        unsigned int memory_consumption;
        unsigned int checksum;
        time_t       revalidate;
    } dynamic_members;
} zend_persistent_script;

主要字段说明如下。

  • script:zend_script结构体,存储文件名、oparray、相关方法和类。

  • timestamp:文件的更新时间。

  • is_phar:文件是phar类型,判断方法如下。

    new_persistent_script->is_phar =
        new_persistent_script->script.filename &&
        strstr(ZSTR_VAL(new_persistent_script->script.filename), ".phar") &&
        !strstr(ZSTR_VAL(new_persistent_script->script.filename), "://");
    
  • mem:指向所在共享内存的位置。

  • size:初始化为结构体zend_persistent_script的大小。

第二步,注册内部模块accel。opcache的相关函数都注册在这个内部模块下:

static zend_function_entry accel_functions[] = {
    /* User functions */
    ZEND_FE(opcache_reset, arginfo_opcache_none)
    ZEND_FE(opcache_invalidate, arginfo_opcache_invalidate)
    ZEND_FE(opcache_compile_file, arginfo_opcache_compile_file)
    ZEND_FE(opcache_is_script_cached, arginfo_opcache_is_script_cached)
    /* Private functions */
    ZEND_FE(opcache_get_configuration, arginfo_opcache_none)
    ZEND_FE(opcache_get_status, arginfo_opcache_get_status)
    ZEND_FE_END
};

第三步,校验opcache是否支持当前的sapi。opcache只支持下面几种sapi。

static const char *supported_sapis[] = {
    "apache",
    "fastcgi",
    "cli-server",
    "cgi-fcgi",
    "fpm-fcgi",
    "isapi",
    "apache2filter",
    "apache2handler",
    "litespeed",
    "uwsgi",
    NULL
};

看上去opcache不支持CLI模式,其实不然。如果想要opcache支持CLI,需要单独在php.ini中设置。

opcache.enable_cli = 1

第四步,分配并初始化共享内存。此步在函数zend_shared_alloc_startup中进行,共享内存的大小在php.ini中配置:

opcache.memory_consumption = 64

配置中的单位为MB,管理共享内存的全局变量为smm_shared_globals,其为指向结构体zend_smm_shared_globals的指针。

typedef struct _zend_smm_shared_globals {
    /* Shared Memory Manager */
    zend_shared_segment      **shared_segments;
    /* Number of allocated shared segments */
    int                         shared_segments_count;
    /* Amount of free shared memory */
    size_t                      shared_free;
    /* Amount of shared memory allocated by garbage */
    size_t                      wasted_shared_memory;
    /* No more shared memory flag */
    zend_bool                   memory_exhausted;
    /* Saved Shared Allocator State */
    zend_shared_memory_state   shared_memory_state;
    /* Pointer to the application's shared data structures */
    void                       *app_shared_globals;
} zend_smm_shared_globals;

主要字段说明如下。

  • shared_segments:二级指针,最终指向结构体zend_shared_segment。由于一次性申请内存大小的限制,php.ini中设置的内存大小可能被分成多块内存来存储在zend_shared_segment中:

    typedef struct _zend_shared_segment {
        size_t  size;  // 此块内存的大小
        size_t  pos;   // 此块内存在整块内存中的位置
        void   *p;     // 指向此块内存的指针
    } zend_shared_segment;
    
  • shared_segments_count:大块内存分成的小块内存的个数,根据申请内存的方式不同而不同。

申请流程:首先抢占fcntl文件锁,保证此共享内存只会申请一次;真正实现内存分配的函数总共有4种,即mmap、shm、posix以及win32,对应的函数名分别是zend_alloc_mmap_handlers、zend_alloc_shm_handlers、zend_alloc_posix_handlers、zend_alloc_win32_handlers。申请内存时,优先使用php.ini中memory_model字段配置的申请模式,如果申请失败,则再依次使用全局变量handler_table配置的申请内存的模式重新申请,直到成功,如果遍历结束后仍然未申请成功,则返回申请失败;最后对全局变量smm_shared_globals指向的数据进行初始化。

int zend_shared_alloc_startup(size_t requested_size)
{
    ……
    if (ZCG(accel_directives).memory_model && ZCG(accel_directives).memory_model[0]) {
        ……
    }

    if (! g_shared_alloc_handler) {
        /* try memory handlers in order */
        for (he = handler_table; he->name; he++) {
            res = zend_shared_alloc_try(he, requested_size, &ZSMMG(shared_segments),
                &ZSMMG(shared_segments_count), &error_in);
            if (res) {
                /* this model works! */
                break;
            }
        }
    }
    ……
}

第五步,初始化全局变量accel_shared_globals指向的结构体zend_accel_shared_globals,其结构体如下。

typedef struct _zend_accel_shared_globals {
    /* Cache Data Structures */
    zend_ulong   hits;
    zend_ulong   misses;
    zend_ulong   blacklist_misses;
    zend_ulong   oom_restarts;  /* number of restarts because of out of memory */
    zend_ulong   hash_restarts; /* number of restarts because of hash overflow */
    zend_ulong   manual_restarts; /* number of restarts scheduled by opcache_reset() */
    zend_accel_hash hash;              /* hash table for cached scripts */

    /* Directives & Maintenance */
    time_t          start_time;
    time_t          last_restart_time;
    time_t          force_restart_time;
    zend_bool       accelerator_enabled;
    zend_bool       restart_pending;
    zend_accel_restart_reason restart_reason;
    zend_bool       cache_status_before_restart;
    zend_bool       restart_in_progress;
    /* Interned Strings Support */
    char           *interned_strings_start;
    char           *interned_strings_top;
    char           *interned_strings_end;
    char           *interned_strings_saved_top;
    HashTable       interned_strings;
} zend_accel_shared_globals;

首先初始化字段hash,其类型为_zend_accel_hash,这是opcache自已实现的HashTable,结构体如下:

typedef struct _zend_accel_hash {
    zend_accel_hash_entry **hash_table;
    zend_accel_hash_entry  *hash_entries;
    uint32_t                num_entries;
    uint32_t                max_num_entries;
    uint32_t                num_direct_entries;
} zend_accel_hash;

struct _zend_accel_hash_entry {
    zend_ulong              hash_value;
    char                   *key;
    uint32_t               key_length;
    zend_accel_hash_entry *next;
    void                   *data;
    zend_bool               indirect;
};

zend_accel_hash结构中的max_num_entries为可存储的元素最大个数,可以在php.ini中配置:

opcache.max_accelerated_files = 1024

注意,这个配置只是名义上最大的缓存个数,实际可缓存的个数是从下面的prime_numbers中找到的第一个大于配置的数字的素数:

static uint prime_numbers[] =
    {5, 11, 19, 53, 107, 223, 463, 983, 1979, 3907, 7963, 16229, 32531, 65407, 130987,
        262237, 524521, 1048793 };

然后初始化interned_strings,这里的HashTable为第5章介绍的_zend_array,用来存储内部字符串(interned strings)。此HashTable的元素个数与interned_strings_buffer字段相关,此字段在php.ini中配置:

opcache.interned_strings_buffer = 8

其单位为MB,代表缓存的内部字符串内存上限。opcache假定平均每个字符串长度为8,再加上PHP 7的字符串存储结构体zend_string中的其他字段,得到每个字符串的平均长度_ZSTR_STRUCT_SIZE(8),所以此HashTable的初始化元素个数为interned_strings_buffer× 1024× 1024 / _ZSTR_STRUCT_SIZE(8)。

字段interned_strings_start为存储缓存字符串的内存首地址,显然,此块内存的大小为interned_strings_buffer× 1024× 1024,字段interned_strings_top指向interned_strings_start的头部,interned_strings_end指向interned_strings_start的尾部。

第六步,hook相关函数:

/* Override compiler */
zend_compile_file = persistent_compile_file;

/* Override stream opener function (to eliminate open() call caused by
  * include/require statements ) */
zend_stream_open_function = persistent_stream_open_function;
/* Override path resolver function (to eliminate stat() calls caused by
  * include_once/require_once statements */
zend_resolve_path = persistent_zend_resolve_path;

/* Override chdir() function */
if ((func = zend_hash_str_find_ptr(CG(function_table), "chdir", sizeof("chdir")-1)) ! =
    NULL &&
    func->type == ZEND_INTERNAL_FUNCTION) {
    orig_chdir = func->internal_function.handler;
    func->internal_function.handler = ZEND_FN(accel_chdir);
}

/* Override "include_path" modifier callback */
if ((ini_entry = zend_hash_str_find_ptr(EG(ini_directives), "include_path", sizeof
    ("include_path")-1)) ! = NULL) {
    ZCG(include_path) = ini_entry->value;
    orig_include_path_on_modify = ini_entry->on_modify;
    ini_entry->on_modify = accel_include_path_on_modify;
}

/* Override file_exists(), is_file() and is_readable() */
zend_accel_override_file_functions();

至此,opcache的启动基本完成,下面我们主要来看下对编译函数的hookpersistent_compile_file的实现。

zend_op_array *persistent_compile_file(zend_file_handle *file_handle, int type)
{
    if (! file_handle->filename || ! ZCG(enabled) || ! accel_startup_ok) {
        ……
    }

    if (! ZCG(accel_directives).revalidate_path) {
        /* try to find cached script by key */
        key = accel_make_persistent_key(file_handle->filename, strlen(file_handle->
            filename), &key_length);
        if (! key) {
            return accelerator_orig_compile_file(file_handle, type);
        }
        persistent_script = zend_accel_hash_str_find(&ZCSG(hash), key, key_length);
    }

    /* if turned on - check the compiled script ADLER32 checksum */
    if (persistent_script && ZCG(accel_directives).consistency_checks
    && persistent_script->dynamic_members.hits % ZCG(accel_directives).consistency_
        checks == 0) {

        unsigned int checksum = zend_accel_script_checksum(persistent_script);
        if (checksum ! = persistent_script->dynamic_members.checksum ) {
            /* The checksum is wrong */
            ……
            persistent_script = NULL;
        }
    }

    return zend_accel_load_script(persistent_script, from_shared_memory);
}

具体步骤如下。

  1. 校验opcache是否可用,是否已初始化完成,如果不可用或者初始化未成功,则执行原来的编译逻辑。

  2. 判断是否开启了文件路径验证(ZCG(accel_directives).revalidate_path):

    opcache.revalidate_path = 1
    

    默认状态为关闭。如果关闭,则查找cache时的索引,只能通过文件名、当前工作路径和ZCG(include_path)来生成(ZCG(include_path)的值在php.ini中的include_path字段配置);如果已开启,则直接使用文件的全路径来查找cache。

  3. 判断是否开启缓存的有效期验证(ZCG(accel_directives).validate_timestamps):

    opcache.validate_timestamps = 1
    

    默认状态为开启。如果关闭,则必须使用opcache_set或者opcache_invalidate函数来手动重置opcache,也可以通过重启Web服务器来使文件系统更改生效;如果开启,则每隔opcache.revalidate_freq秒检查一次文件是否有更新,如果有更新则重新编译。

  4. 校验cache是否合法。为了防止在读取cache的过程中数据被其他进程修改,导致读取到的cache数据异常,需对cache进行校验。

    进行校验的算法为Adler-32。Adler-32是Mark Adler发明的校验和算法,和32位CRC校验算法一样,用于保护数据以防止其被意外更改,但是这个算法较容易被伪造,所以是不安全的。但是比起CRC,它的计算速度很快。这个算法是在Fletcher校验和算法的基础上修改而成的,原始的算法形式略快,但是可依赖性并不高。

  5. 返回cache中的数据或者把重新编译生成的结果存入cache中后返回。

14.4 自定义扩展

前边我们介绍了PHP扩展的实现原理,本节我们来看下如何编写一个自己的扩展。

假定我们要经常获取文件的总行数,命令行下可以直接通过“wc -lfilepath”获取,但是我们希望可以做成PHP内置函数来使用,例如:

<?php
$line = wcl('test.txt');

14.4.1 初始化

首先需要生成扩展的基本框架。PHP官方提供了一个构造器ext_skel以帮助我们生成扩展所必要的文件和基本框架,该文件位于源码目录下的ext目录:

$ ll ext_skel
-rwxr-xr-x 1 vagrant vagrant 8.5K 4月  112017 ext_skel
$ ./ext_skel -help
./ext_skel --extname=module [--proto=file] [--stubs=file] [--xml[=file]]
            [--skel=dir] [--full-xml] [--no-help]

ext_skel有几个参数,其中,--extname参数用来指定要创建的扩展名称,是一个全为小写字母的标识符,仅包含字母和下划线,需要在ext目录下保持唯一;--proto用来指定函数原型(这一步可以省略)。例如,我们创建一个名为wcl的PHP扩展,首先定义函数原型文件:

$ touch wcl.def
$ cat wcl.def
int wcl(string filename)

接下来生成扩展框架:

$ ./ext_skel --extname=wcl --proto=wcl.def
Creating directory wcl
Creating  basic  files:  config.m4  config.w32  .gitignore  wcl.c  php_wcl.h  CREDITS
    EXPERIMENTAL tests/001.phpt wcl.php [done].

To use your new extension, you will have to execute the following steps:

1.  $ cd ..
2.  $ vi ext/wcl/config.m4
3.  $ ./buildconf
4.  $ ./configure --[with|enable]-wcl
5.  $ make
6.  $ ./sapi/cli/php -f ext/wcl/wcl.php
7.  $ vi ext/wcl/wcl.c
8.  $ make

Repeat steps 3-6 until you are satisfied with ext/wcl/config.m4 and
step 6 confirms that your module is compiled into PHP. Then, start writing
code and repeat the last two steps as often as necessary.

$ ll wcl
-rw-r--r--1 vagrant vagrant 2.0K 1月  25 15:20 config.m4
-rw-r--r--1 vagrant vagrant  334 1月  25 15:20 config.w32
-rw-r--r--1 vagrant vagrant    3 1月  25 15:20 CREDITS
-rw-r--r--1 vagrant vagrant    0 1月  25 15:20 EXPERIMENTAL
-rw-r--r--1 vagrant vagrant 2.3K 1月  25 15:20 php_wcl.h
drwxr-xr-x 2 vagrant vagrant   21 1月  25 15:20 tests
-rw-r--r--1 vagrant vagrant 5.3K 1月  25 15:20 wcl.c
-rw-r--r--1 vagrant vagrant  493 1月  25 15:20 wcl.php

命令执行完,可以看到在当前目录下已经有了一个名为wcl的目录,目录中有很多文件。

  • config.m4:autoconf语法规则的编译配置文件,它可以指定扩展支持的configure选项以及扩展需要的额外的库,包含哪些源文件等。
  • config.w32:Windows平台下的编译配置文件,它的作用同config.m4,但是它是使用JavaScript编写的。
  • CREDITS:用纯文本格式列出了扩展的贡献者和维护者。文件的第一行应保存扩展的名称,第二行是用逗号分隔的贡献者名单。
  • EXPERIMENTAL:实验功能说明文件。
  • php_wcl.h:当将扩展作为静态模块构建并放入PHP二进制包时,构建系统要求用php_ 加扩展的名称命名的头文件包含一个对扩展模块结构的指针定义。就像其他头文件,此文件经常包含附加的宏、原型和全局变量。
  • tests:测试脚本目录。
  • wcl.c:扩展的主要源文件,通常,此文件名就是扩展的文件名。此文件包含模块结构定义、ini配置项、扩展提供的函数和其他扩展所需的内容。
  • wcl.php:测试脚本,可以输出扩展支持的函数列表以及当前扩展是否已经被编译到PHP。

14.4.2 编译配置

框架初始化之后,我们来看下如何修改编译配置文件。


这里补充一个小细节:读者可能注意到我们在编译安装PHP的时候,会指定一些编译配置选项,有些是-with,有些是-enable,这里有什么区别呢?一般来说,enable表示某个内置功能是否开启,而with表示是否需要添加某个功能,通常需要指定依赖的外部库。


wcl扩展不依赖外部组件,所以这里选择enable方式。修改config.m4,去掉PHP_ARG_ENABLE和--enable-wcl两行前面的dnl(在autoconf语法中,dnl表示注释)。修改后如下:

PHP_ARG_ENABLE(wcl, whether to enable wcl support,
dnl Make sure that the comment is aligned:
[  --enable-wcl           Enable wcl support])

if test "$PHP_WCL" ! = "no"; then
PHP_NEW_EXTENSION(wcl,  wcl.c,  $ext_shared, ,  -DZEND_ENABLE_STATIC_TSRMLS_CACHE=1)
fi

宏说明如下。

  • PHP_ARG_ENABLE宏:第一个参数表示扩展名称;第二个参数在执行./configure处理到该扩展时,显示该参数的内容;第三个参数是执行./configure -help的输出信息。
  • PHP_NEW_EXTENSION宏:声明了扩展的名称、源文件列表(多个文件的时候在文件名称后边加空格,如果需要换行还需加上反斜杠“\”)、此扩展是动态库还是静态库,扩展是否只能在CLI或CGI模式下运行等。

更多的宏定义参见源码目录下的acinclude.m4文件。在14.2.1节中,我们提到使用动态链接库方式的扩展还需要实现get_module方法,这里exk_skel帮我们做了这个工作:

#ifdef COMPILE_DL_WCL
#ifdef ZTS
ZEND_TSRMLS_CACHE_DEFINE()
#endif
ZEND_GET_MODULE(wcl)
#endif

14.4.3 功能实现

我们在生成扩展框架的时候已经指定了函数原型,ext_skel会自动生成该函数的基本定义,并通过宏PHP_FE把函数注册到zend_function_entry。如果不指定原型函数,那么这两步需要我们手动完成。另外,每个扩展都会注册一个名为confirm_扩展名称_compiled的函数用来输出当前扩展是否已经被编译到PHP:

/* {{{ proto int wcl(string filepath)
    */
PHP_FUNCTION(wcl)
{
    char *filepath = NULL;
    int argc = ZEND_NUM_ARGS();
    size_t filepath_len;

    if (zend_parse_parameters(argc, "s", &filepath, &filepath_len) == FAILURE) {
        return;
    }
    php_error(E_WARNING, "wcl: not yet implemented");
}
/* }}} */
/* {{{ wcl_functions[]
  *
  * Every user visible function must have an entry in wcl_functions[].
  */
const zend_function_entry wcl_functions[] = {
    PHP_FE(confirm_wcl_compiled, NULL)           /* For testing, remove later. */
    PHP_FE(wcl,    NULL)
    PHP_FE_END     /* Must be the last line in wcl_functions[] */
};
/* }}} */

扩展中函数的定义由PHP_FUNCTION宏来完成,实际展开如下:

void zif_wcl(zend_execute_data *execute_data, zval *return_value)

zend_parse_parameters用来对函数参数做校验并获取函数的参数。第一个参数为传递给函数的参数个数,通常ZEND_NUM_ARGS来获取;第二个参数指定函数的参数类型,其后是要解析的参数。

接下来我们修改PHP_FUNCTION(wcl) 实现功能即可,打开wcl.c,修改这个函数为以下代码:

PHP_FUNCTION(wcl)
{
    char *filepath = NULL;
    int argc = ZEND_NUM_ARGS();
    size_t filepath_len;
    char ch;
    FILE *fp;
    Zend_long lcount = 0;

    if (zend_parse_parameters(argc, "s", &filepath, &filepath_len) == FAILURE)
    {
        return;
    }

    /* php_error(E_WARNING, "wcl: not yet implemented"); */

    if ((fp = fopen(filepath, "r")) == NULL) {
        RETURN_FALSE;
    }

    while ((ch = fgetc(fp)) ! = EOF) {
        if (ch == '\n') {
            lcount++;
        }
    }
    fclose(fp);
    RETURN_LONG(lcount);
}

这个函数在无法打开文件的时候返回false,正常情况下遍历文件内容判断换行符,如果当前字符为换行符,则计数加1,最终返回文件总行数。

这里的RETURN_FALSE和RETURN_LONG是用于从函数中返回值的宏,分别表示返回false和long类型的返回值,类似的还有RETVAL_NULL、RETVAL_BOOL、RETVAL_TRUE、RETVAL_DOUBLE、RETVAL_STRING、RETVAL_STRINGL、RETVAL_RESOURCE,见名思义,这里就不再逐一解释了。

14.4.4 注册配置

项如果不考虑各种边界问题,这个扩展的基本功能就完成了,后边只需要安装并启用这个扩展即可,但是这里我们稍微扩展下:注册一个ini配置项来控制是否计算空行(暂时只考虑首字符为换行符的情况)。


注意

配置解析相关内容请参考第8章,这里不再赘述。


在php_wcl.h中声明扩展内的全局变量:

ZEND_BEGIN_MODULE_GLOBALS(wcl)
    Zend_long filter_blank;
ZEND_END_MODULE_GLOBALS(wcl)

在wcl.c中添加配置项:

ZEND_DECLARE_MODULE_GLOBALS(wcl)

PHP_INI_BEGIN()
    STD_PHP_INI_ENTRY("wcl.filter_blank", "0", PHP_INI_ALL, OnUpdateBool, filter_
        blank, zend_wcl_globals, wcl_globals)
PHP_INI_END()

以上代码表示为当前扩展注册了一个配置项“wcl.filter_blank”,其默认值为0。

接下来,我们修改前边的PHP_FUNCTION(wcl),在函数中获取这个配置项:

PHP_FUNCTION(wcl)
{
    char *filepath = NULL;
    int argc = ZEND_NUM_ARGS();
    size_t filepath_len;
    char ch, pre = '\n';
    FILE *fp;
    zend_long lcount = 0;

    if (zend_parse_parameters(argc, "s", &filepath, &filepath_len) == FAILURE)
    {
        return;
    }

    /* php_error(E_WARNING, "wcl: not yet implemented"); */

    if ((fp = fopen(filepath, "r")) == NULL) {
        RETURN_FALSE;
    }

    while ((ch = fgetc(fp)) ! = EOF) {
        if (ch == '\n') {
            if (WCL_G(filter_blank) && pre == ch) {
                continue;
            }
            lcount++;
        }
        pre = ch;
    }
    fclose(fp);

    RETURN_LONG(lcount);
}

扩展内可以通过注册到WCL_G的配置项来获取配置。到这里还差一步就可以真正使用它了。前面我们学习了PHP有五大阶段,每个扩展都会通过实现这五个阶段的钩子函数来完成相关工作,具体如下:

/* {{{ wcl_module_entry
  */
zend_module_entry wcl_module_entry = {
    STANDARD_MODULE_HEADER,
    "wcl",
    wcl_functions,
    PHP_MINIT(wcl),
    PHP_MSHUTDOWN(wcl),
    PHP_RINIT(wcl), /* Replace with NULL if there's nothing to do at request start */
    PHP_RSHUTDOWN(wcl), /* Replace with NULL if there's nothing to do at request end */
    PHP_MINFO(wcl),
    PHP_WCL_VERSION,
    STANDARD_MODULE_PROPERTIES
};
/* }}} */

其中,配置项的注册是在模块初始化阶段PHP_MINIT这一步完成的,在wcl.c文件找到PHP_MINIT_FUNCTION函数,可以看到里边已经有如下注释(我们只需要打开这个注释即可,wcl扩展在启动的时候会自动注册当前扩展的配置项):

/* {{{ PHP_MINIT_FUNCTION
  */
PHP_MINIT_FUNCTION(wcl)
{
    /* If you have INI entries, uncomment these lines
    REGISTER_INI_ENTRIES();
    */
    return SUCCESS;
}
/* }}} */

相应地,在模块关闭阶段PHP_MSHUTDOWN中,也需要注销配置项,在PHP_MSHUTDOWN_FUNCTION函数中打开对应的注释即可。

14.4.5 编译、安装

完成了扩展功能的实现,并注册了相应的配置项后,只需要编译并安装就可以在PHP程序中使用了:

$ pwd
/home/vagrant/php-7.1.0/output/ext/wcl
$ /home/vagrant/php-7.1.0/output/bin/phpize
Configuring for:
PHP Api Version:         20160303
Zend Module Api No:      20160303
Zend Extension Api No:   320160303
$ ./configure --with-php-config=/home/vagrant/php-7.1.0/output/bin/php-config
// 编译过程略
$ make && make install
Installing  shared  extensions:        /home/vagrant/php-7.1.0/output/lib/php/
    extensions/no-debug-non-zts-20160303/
$ ll /home/vagrant/php-7.1.0/output/lib/php/extensions/no-debug-non-zts-20160303/
-rwxr-xr-x 1 root root  33K 1月  27 05:49 wcl.so

可以看到,扩展对应的目录下已经有对应的wcl.so文件了,之后的步骤想必读者已经很熟悉了,只需要打开php.ini文件启用扩展并配置对应的配置项即可。配置完成以后,执行以下命令:

$ php -f ext/wcl/wcl.php
Functions available in the test extension:
confirm_wcl_compiled
wcl

Congratulations! You have successfully modified ext/wcl/config.m4. Module wcl is now compiled into PHP.

可以看到,wcl已经成功编译到PHP,并且该扩展提供了两个函数。现在我们可以在PHP代码中直接使用wcl函数了。

14.5 本章小结

本章从扩展的原因,讲到扩展的两种分类——PHP扩展和Zend扩展及其异同,分别讲了两种扩展的实现原理,并仔细研究了一个实现,最后又详细描述了一个简易PHP扩展开发示例。其实PHP官方提供的扩展开发工具不是特别好用,在此推荐一款更加灵活好用的第三方开源工具——PHP-X。通过此工具,开发者可以更加高效地开发PHP扩展。