PHP 扩展 OPcache 的机制简介

682 阅读11分钟

  作为一种脚本语言,PHP 在每次运行时首先要进行解释,在解释得到 OPCode 之后再运行这些 OPCode,运行完之后立刻销毁。在每一次新的请求到来时,PHP 会重复上述过程。然而,在生产环境,PHP 代码并不会频繁的发生改变,每次请求中解释都会得到相同的 OPCode,既费时间也耗资源。

  基于上述原因,缓存 OPCode 的扩展被设计出来,其目的是每个 PHP 脚本仅解释一次,然后将得到的 OPCode 缓存入共享内存。这样,所有的 PHP-FPM 的 worker 进程都可以从共享内存中直接读取 OPCode 然后运行,省去了解释脚本的时间。

⒈ 共享内存模型

  OPCache 默认支持四种共享内存模型:

  • mmap
  • shm
  • posix
  • win32

  可以通过修改 PHP 的配置文件中的配置项 opcache.preferred_memory_model 来选择 OPcache 使用哪种模型。如果留空,则按照以下顺序选择当前操作系统支持的第一种模型

static const zend_shared_memory_handler_entry handler_table[] = { 
#ifdef USE_MMAP
    { "mmap", &zend_alloc_mmap_handlers }, 
#endif 
#ifdef USE_SHM 
    { "shm", &zend_alloc_shm_handlers }, 
#endif 
#ifdef USE_SHM_OPEN 
    { "posix", &zend_alloc_posix_handlers }, 
#endif 
#ifdef ZEND_WIN32 
    { "win32", &zend_alloc_win32_handlers }, 
#endif 
    { NULL, NULL} 
};

  OPCache 在启动时会从内存中申请一块内存空间,该内存空间一经分配之后将不会释放,其大小也不会发生改变。这块内存空间如何使用完全由 OPCache 决定。至于这块内存空间的大小,可以通过 PHP 配置文件中的配置项 opcache.memory_consumption 来设置。这块内存空间中所存储内容主要有以下几种:

  • 脚本的数据结构缓存(其中就包括 OPCode 缓存)
  • 共享的 interned string
  • 脚本的 hashtable
  • 全局的 OPCache 的共享内存的状态

⒉ OPCodes 缓存

  OPCache 的总体思路就是将各个请求之间不变的数据存入共享内存中。之后,一旦加载回相同的脚本,再将这些数据从共享内存恢复到进程内存中,并绑定到当前的请求。所以,OPCache 的运行是在 PHP 脚本的运行之前。

PHP 脚本在解释过程中,凡是通过解释器分配内存的结构都认为是不变量,这些量会被存入共享内存,包括但不限于函数名、函数的 OPArray、类常量、类属性名、类属性的默认值…… 变化的量只能在脚本运行时通过 ZVM 分配内存。

  OPCache 作为 PHP 解释器的钩子,在 PHP 脚本的解释过程中,会将 PHP 解释器需要填充的数据结构换成自身的数据结构 persistent_script。

typedef struct _zend_script {
    zend_string   *filename;
    zend_op_array  main_op_array;
    HashTable      function_table;
    HashTable      class_table;
    uint32_t       first_early_binding_opline; /* the linked list of delayed declarations */
} zend_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;
    zend_bool      empty;

    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;
#ifdef ZEND_WIN32
        LONGLONG   hits;
#else
        zend_ulong        hits;
#endif
        unsigned int memory_consumption;
        unsigned int checksum;
        time_t       revalidate;
    } dynamic_members;
} zend_persistent_script;

/*将 PHP 哦本的 function 写入 persistent_script 的 function_table*/
void zend_accel_move_user_functions(HashTable *src, uint32_t count, zend_script *script)
{
    Bucket *p, *end;
    HashTable *dst;
    zend_string *filename;
    dtor_func_t orig_dtor;
    zend_function *function;

    if (!count) {
        return;
    }

    dst = &script->function_table;
    filename = script->main_op_array.filename;
    orig_dtor = src->pDestructor;
    src->pDestructor = NULL;
    zend_hash_extend(dst, count, 0);
    end = src->arData + src->nNumUsed;
    p = end - count;
    for (; p != end; p++) {
        if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) continue;
        function = Z_PTR(p->val);
        if (EXPECTED(function->type == ZEND_USER_FUNCTION)
         && EXPECTED(function->op_array.filename == filename)) {
            _zend_hash_append_ptr(dst, p->key, function);
            zend_hash_del_bucket(src, p);
        }
    }
    src->pDestructor = orig_dtor;
}

/*将 PHP 本的 class 写入 persistent_script 的 class_table*/
void zend_accel_move_user_classes(HashTable *src, uint32_t count, zend_script *script)
{
    Bucket *p, *end;
    HashTable *dst;
    zend_string *filename;
    dtor_func_t orig_dtor;
    zend_class_entry *ce;

    if (!count) {
        return;
    }

    dst = &script->class_table;
    filename = script->main_op_array.filename;
    orig_dtor = src->pDestructor;
    src->pDestructor = NULL;
    zend_hash_extend(dst, count, 0);
    end = src->arData + src->nNumUsed;
    p = end - count;
    for (; p != end; p++) {
        if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) continue;
        ce = Z_PTR(p->val);
        if (EXPECTED(ce->type == ZEND_USER_CLASS)
         && EXPECTED(ce->info.user.filename == filename)) {
            _zend_hash_append_ptr(dst, p->key, ce);
            zend_hash_del_bucket(src, p);
        }
    }
    src->pDestructor = orig_dtor;
}}

//*解释 PHP 奥本,同时填充 persistent_script*/
sstatic zend_persistent_script *opcache_compile_file(zend_file_handle *file_handle, int type, const char *key, zend_op_array **op_array_p)
{{
    zend_persistent_script *new_persistent_script;
    uint32_t orig_functions_count, orig_class_count;
    zend_op_array *orig_active_op_array;
    zval orig_user_error_handler;
    zend_op_array *op_array;
    int do_bailout = 0;
    accel_time_t timestamp = 0;
    uint32_t orig_compiler_options = 0;
    
    /*... ...*/
    
    /* Save the original values for the op_array, function table and class table */
    orig_active_op_array = CG(active_op_array);
    orig_functions_count = EG(function_table)->nNumUsed;
    orig_class_count = EG(class_table)->nNumUsed;
    ZVAL_COPY_VALUE(&orig_user_error_handler, &EG(user_error_handler));

    /* Override them with ours */
    ZVAL_UNDEF(&EG(user_error_handler));

    zend_try {
        orig_compiler_options = CG(compiler_options);
        CG(compiler_options) |= ZEND_COMPILE_HANDLE_OP_ARRAY;
        CG(compiler_options) |= ZEND_COMPILE_IGNORE_INTERNAL_CLASSES;
        CG(compiler_options) |= ZEND_COMPILE_DELAYED_BINDING;
        CG(compiler_options) |= ZEND_COMPILE_NO_CONSTANT_SUBSTITUTION;
        CG(compiler_options) |= ZEND_COMPILE_IGNORE_OTHER_FILES;
        if (ZCG(accel_directives).file_cache) {
            CG(compiler_options) |= ZEND_COMPILE_WITH_FILE_CACHE;
        }
        op_array = *op_array_p = accelerator_orig_compile_file(file_handle, type);
        CG(compiler_options) = orig_compiler_options;
    } zend_catch {
        op_array = NULL;
        do_bailout = 1;
        CG(compiler_options) = orig_compiler_options;
    } zend_end_try();
    
    /* Restore originals */
    CG(active_op_array) = orig_active_op_array;
    EG(user_error_handler) = orig_user_error_handler;
	
    /*... ...*/
    
    /* Build the persistent_script structure.
       Here we aren't sure we would store it, but we will need it
       further anyway.
    */
    new_persistent_script = create_persistent_script();
    new_persistent_script->script.main_op_array = *op_array;
    zend_accel_move_user_functions(CG(function_table), CG(function_table)->nNumUsed - orig_functions_count, &new_persistent_script->script);
    zend_accel_move_user_classes(CG(class_table), CG(class_table)->nNumUsed - orig_class_count, &new_persistent_script->script);
    new_persistent_script->script.first_early_binding_opline =
        (op_array->fn_flags & ZEND_ACC_EARLY_BINDING) ?
            zend_build_delayed_early_binding_list(op_array) :
            (uint32_t)-1;

    efree(op_array); /* we have valid persistent_script, so it's safe to free op_array */

    /* Fill in the ping_auto_globals_mask for the new script. If jit for auto globals is enabled we
       will have to ping the used auto global variables before execution */
    if (PG(auto_globals_jit)) {
        new_persistent_script->ping_auto_globals_mask = zend_accel_get_auto_globals();
    } else {
        new_persistent_script->ping_auto_globals_mask = zend_accel_get_auto_globals_no_jit();
    }    

    if (ZCG(accel_directives).validate_timestamps) {
        /* Obtain the file timestamps, *before* actually compiling them,
         * otherwise we have a race-condition.
         */
        new_persistent_script->timestamp = timestamp;
        new_persistent_script->dynamic_members.revalidate = ZCG(request_time) + ZCG(accel_directives).revalidate_freq;
    }    

    if (file_handle->opened_path) {
        new_persistent_script->script.filename = zend_string_copy(file_handle->opened_path);
    } else {
        new_persistent_script->script.filename = zend_string_init(file_handle->filename, strlen(file_handle->filename), 0);
    }    
    zend_string_hash_val(new_persistent_script->script.filename);

    /* Now persistent_script structure is ready in process memory */
    return new_persistent_script;
 }

  由以上代码可知,PHP 解释器在解释 PHP 脚本时,将原先写入全局 OPArrayfunction_tableclass_table 的数据,现在全部写入了新创建的 persistent_script 结构体中。

  现在 persistent_script 结构体已经创建并填充完成,该过程发生在 PHP 解释器解释 PHP 脚本的过程中,此时 persistent_script 的内存由 ZMM 分配,存在于 php-fpm 进程的内存中。根据 PHP 的特性,在本次请求处理完后,php-fpm 进程中的内存会被完全释放。所以,在内存释放之前,我们需要将 persistent_script 结构体及其中的数据复制到共享内存中,这样我们可以在以后的请求中复用这些信息,而不用每次都重新计算。

  复制流程大致如下:

  • 计算所需要的内存空间大小
  • 在共享内存中分配相应大小的空间
  • persistent_script 中的数据进行迭代,将不变的量复制到共享内存

  在对共享内存进行管理时,OPCache 既不会主动释放内存,也不会进行空间压缩。对于每个 persistent_script,OPCache 会精确的计算其所需要存入共享内存的数据所需要的空间大小,然后为其分配相应大小的内存空间,然后将数据存入其中。当共享内存中的数据失效时,OPCache 并不会将其释放,而是将相应的内存标记为 wasted,当被标记为 wasted 的内存空间达到上限时(通过 opcache.max_wasted_percentage 设置),OPCache 会重启,此时共享内存会重新分配。

/*计算 persistent_script 中需要复制到共享内存中的数据需要的内存空间大小*/
uint32_t zend_accel_script_persist_calc(zend_persistent_script *new_persistent_script, const char *key, unsigned int key_length, int for_shm)
{
    Bucket *p;

    new_persistent_script->mem = NULL;
    new_persistent_script->size = 0;
    new_persistent_script->arena_mem = NULL;
    new_persistent_script->arena_size = 0;
    new_persistent_script->corrupted = 0;
    ZCG(current_persistent_script) = new_persistent_script;

    if (!for_shm) {
        /* script is not going to be saved in SHM */
        new_persistent_script->corrupted = 1;
    }

    ADD_SIZE(sizeof(zend_persistent_script));
    if (key) {
        ADD_SIZE(key_length + 1);
        zend_shared_alloc_register_xlat_entry(key, key);
    }
    ADD_STRING(new_persistent_script->script.filename);

#if defined(__AVX__) || defined(__SSE2__)
    /* Align size to 64-byte boundary */
    new_persistent_script->size = (new_persistent_script->size + 63) & ~63;
#endif

    if (new_persistent_script->script.class_table.nNumUsed != new_persistent_script->script.class_table.nNumOfElements) {
        zend_hash_rehash(&new_persistent_script->script.class_table);
    }
    zend_accel_persist_class_table_calc(&new_persistent_script->script.class_table);
    if (new_persistent_script->script.function_table.nNumUsed != new_persistent_script->script.function_table.nNumOfElements) {
        zend_hash_rehash(&new_persistent_script->script.function_table);
    }
    zend_hash_persist_calc(&new_persistent_script->script.function_table);
    ZEND_HASH_FOREACH_BUCKET(&new_persistent_script->script.function_table, p) {
        ZEND_ASSERT(p->key != NULL);
        ADD_INTERNED_STRING(p->key);
        zend_persist_op_array_calc(&p->val);
    } ZEND_HASH_FOREACH_END();
    zend_persist_op_array_calc_ex(&new_persistent_script->script.main_op_array);

#if defined(__AVX__) || defined(__SSE2__)
    /* Align size to 64-byte boundary */
    new_persistent_script->arena_size = (new_persistent_script->arena_size + 63) & ~63;
#endif

    new_persistent_script->size += new_persistent_script->arena_size;
    new_persistent_script->corrupted = 0;

    ZCG(current_persistent_script) = NULL;
    
    return new_persistent_script->size;
}

⒊ 共享 interned strings

  PHP 从 5.4 开始引入了 interned string。所谓 interned string 是指 PHP 将遇到的常量字符串存入内存中特殊的缓冲区间,后续遇到相同的值时再复用,即多个指针共用一份字符串实例。但由于 php-fpm 是进程隔离的,所以导致每个 php-fpm 进程都需要保存一份自己的 interned strings ,导致内存浪费。

interned strings 示意

  OPCache 对 interned strings 机制的改进在于将缓冲区间由 php-fpm 进程的内存移动到共享内存。这样,所有的 php-fpm 进程只需要共用一份 interned strings 缓存。在 PHP 配置文件中可以通过修改配置项 opcache.interned_strings_buffer 的值来调整共享内存中分配给 interned strings 的空间大小。

这里需要注意,如果 opcache.interned_strings_buffer 的值设置的太小,会导致共享内存中分配给 interned strings 的空间过早的耗尽,此时就会出现部分 interned strings 存储在共享内存,部分 interned strings 存储在 php-fpm 进程的内存中的情况。

⒋ OPCache 的内存消耗

共享内存结构示意

  如上图所示,OPCache 的共享内存大致可以分为五部分。其中,前两部分被 OPCache 提前分配,interned strings 部分用来存储程序中的 interned strings,空间大小取决于配置项 opcache.interned_strings_buffer;hashtable 部分用来存储 PHP 文件和 persistent_script 结构体的映射关系,空间大小取决于 opcache.max_accelerated_files。之后的部分存储缓存数据。

  共享内存重新分配

  如果通过设置 opcache.validate_timestampsopcache.revalidate_freq 启用了文件有效性验证,那么 OPCache 会通过定期检查文件的修改时间来判断文件是否被修改。如果文件被修改,那么 OPCache 会将之前的缓存内容标记为 wasted 并且重新对文件数据进行缓存。此时,如果共享内存空间已经耗尽,则 OPCache 会判断是否需要重启以重新分配共享内存。

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

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;

/*判断提前分配的 hashtable 空间是否已满*/
static inline zend_bool zend_accel_hash_is_full(zend_accel_hash *accel_hash)
{
    if (accel_hash->num_entries == accel_hash->max_num_entries) {
        return 1;
    } else {
        return 0;
    }
}

/*为缓存数据分配内存*/
void *zend_shared_alloc(size_t size)
{
    int i;
    unsigned int block_size = ZEND_ALIGNED_SIZE(size);

#if 1
    if (!ZCG(locked)) {
        zend_accel_error(ACCEL_LOG_ERROR, "Shared memory lock not obtained");
    }
#endif
    if (block_size > ZSMMG(shared_free)) { /* No hope to find a big-enough block */
        SHARED_ALLOC_FAILED();
        return NULL;
    }
    for (i = 0; i < ZSMMG(shared_segments_count); i++) {
        if (ZSMMG(shared_segments)[i]->size - ZSMMG(shared_segments)[i]->pos >= block_size) { /* found a valid block */
            void *retval = (void *) (((char *) ZSMMG(shared_segments)[i]->p) + ZSMMG(shared_segments)[i]->pos);

            ZSMMG(shared_segments)[i]->pos += block_size;
            ZSMMG(shared_free) -= block_size;
            ZEND_ASSERT(((zend_uintptr_t)retval & 0x7) == 0); /* should be 8 byte aligned */
            return retval;
        }
    }
    SHARED_ALLOC_FAILED();
    return NULL;
}

/*判断是否需要重启*/
void zend_accel_schedule_restart_if_necessary(zend_accel_restart_reason reason)
{
    if ((((double) ZSMMG(wasted_shared_memory)) / ZCG(accel_directives).memory_consumption) >= ZCG(accel_directives).max_wasted_percentage) {
        zend_accel_schedule_restart(reason);
    }
}

/*将 persistent_script 写入共享内存*/
static zend_persistent_script *cache_script_in_shared_memory(zend_persistent_script *new_persistent_script, const char *key, unsigned int key_length, int *from_shared_memory)
{
    zend_accel_hash_entry *bucket;
    uint32_t memory_used;
    uint32_t orig_compiler_options;
    
    /*... ...*/
    // 提前分配的 hashtable 空间耗尽,判断是否需要重启
    if (zend_accel_hash_is_full(&ZCSG(hash))) {
        zend_accel_error(ACCEL_LOG_DEBUG, "No more entries in hash table!");
        ZSMMG(memory_exhausted) = 1; 
        zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_HASH);
        zend_shared_alloc_unlock();
        if (ZCG(accel_directives).file_cache) {
            new_persistent_script = store_script_in_file_cache(new_persistent_script);
            *from_shared_memory = 1;
        }
        return new_persistent_script;
    }

    /* Calculate the required memory size */
    memory_used = zend_accel_script_persist_calc(new_persistent_script, key, key_length, 1);
    
    ZCG(mem) = zend_shared_alloc(memory_used);
    if (ZCG(mem)) {
        memset(ZCG(mem), 0, memory_used);
    }
    // 存储缓存数据的空间耗尽。判断是否需要重启
    if (!ZCG(mem)) {
        zend_shared_alloc_destroy_xlat_table();
        zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_OOM);
        zend_shared_alloc_unlock();
        if (ZCG(accel_directives).file_cache) {
            new_persistent_script = store_script_in_file_cache(new_persistent_script);
            *from_shared_memory = 1;
        }
        return new_persistent_script;
    }
}

  当 OPCache 为修改的文件重新缓存数据时,如果共享内存没有足够的空间存储缓存数据,此时 OPCache 会判断标记为 wasted 状态的缓存所占的比例是否已经达到 opcache. max_wasted_percentage 所设置的上限,如果超过这个上限,则会重启,否则不会重启。而如果不重启,部分缓存数据将无法写入共享内存,OPCache 的作用将无法有效发挥。在生产环境应该避免这种情况的出现。

  另外,在 hashtable 中存储 persistent_script 时需要用文件路径作为 key 。通常,OPCache 会解析文件的完整路径来作为 key(通过配置项 opcache.revalidate_path 来设置),如果解析不到完整路径,则会使用未解析的路径作为 key。

  如果使用未解析的文件路径作为 key,那么就会出现同一个文件在不同的地方被引用,导致在 hashtable 存在多个 key 实际指向同一个 persistent_script 。配置项 opcache.max_accelerated_files 所设置的为 hashtable 中 key 的数量上限。当 hashtable 中 key的数量达到上限后,OPCache 会判断标记为 wasted 状态的缓存所占的比例是否达到配置文件所设置的上限,从而判断是否需要重启。

现在的 PHP 项目大多使用 autoload 自动加载机制引用文件,所以通常不会出现上述的多个 key 指向同一个 persistent_script 的情况。如果使用 require/include 引用文件,则尽量使用绝对路径。

OPCache 通过 PHP 的 realpath cache 解析文件的路径,如果项目部署使用软链接的形式,那么在新项目部署完成并且 PHP 中的 realpath cache 还没有过期的情况下,OPCache 并不会感知到文件路径的变化,从而新部署的代码不会立马生效。(关于 realpath cache,之前的文章已有论述,次数不再赘述)

/* Adds another key for existing cached script */
static void zend_accel_add_key(const char *key, unsigned int key_length, zend_accel_hash_entry *bucket)
{
    if (!zend_accel_hash_str_find(&ZCSG(hash), key, key_length)) {
        if (zend_accel_hash_is_full(&ZCSG(hash))) {
            zend_accel_error(ACCEL_LOG_DEBUG, "No more entries in hash table!");
            ZSMMG(memory_exhausted) = 1;
            zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_HASH);
        } else {
            char *new_key = zend_shared_alloc(key_length + 1);
            if (new_key) {
                memcpy(new_key, key, key_length + 1);
                if (zend_accel_hash_update(&ZCSG(hash), new_key, key_length, 1, bucket)) {
                    zend_accel_error(ACCEL_LOG_INFO, "Added key '%s'", new_key);
                }
            } else {
                zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_OOM);
            }
        }
    }
}

static zend_persistent_script *cache_script_in_shared_memory(zend_persistent_script *new_persistent_script, const char *key, unsigned int key_length, int *from_shared_memory)
{
    zend_accel_hash_entry *bucket;
    uint32_t memory_used;
    uint32_t orig_compiler_options;

    /*... ...*/
    
    /* exclusive lock */
    zend_shared_alloc_lock();

    /* Check if we still need to put the file into the cache (may be it was
     * already stored by another process. This final check is done under
     * exclusive lock) */
    bucket = zend_accel_hash_find_entry(&ZCSG(hash), new_persistent_script->script.filename);
    if (bucket) {
        zend_persistent_script *existing_persistent_script = (zend_persistent_script *)bucket->data;

        if (!existing_persistent_script->corrupted) {
            if (key &&
                (!ZCG(accel_directives).validate_timestamps ||
                 (new_persistent_script->timestamp == existing_persistent_script->timestamp))) {
                zend_accel_add_key(key, key_length, bucket);
            }
            zend_shared_alloc_unlock();
            return new_persistent_script;
        }
    }
    /*... ...*/
}

  综上,OPCache 重启亦即共享内存重新分配的前提为:

  1. hashtable 中 key 的数量达到上限或空闲的共享内存中没有足够的空间缓存数据
  2. 共享内存中被标记为 wasted 状态的空间所占的比例达到上限

  只有上述条件同时满足,OPCache 才会重启。但在实际应用中,尤其在生产环境中,应该尽量避免 OPCache 的重启。在生产环境中,一旦重启 OPCache,原先的缓存数据全部作废。如果此时有新的请求到达,由于缓存中没有数据,各个 php-fpm 进程都会尝试往共享内存中写入数据,导致发生共享内存的锁争抢。先得到写锁的进程会将缓存数据写入共享内存,然后再将锁释放,后续的进程会重复之前的过程,而一旦有进程在得到写锁后发现自身要缓存的数据已经存在与共享内存中,那么进程会将要写入缓存的数据作废而直接从缓存中加载数据。整个过程会造成大量的资源浪费,严重影响性能。