> 类库下载 > PHP类库 > 본문

PHP 소스 코드 학습 스레드 안전

高洛峰
풀어 주다: 2016-10-09 13:00:09
원래의
1494명이 탐색했습니다.

범위 측면에서 C 언어는 전역 변수, 정적 전역 변수, 지역 변수, 정적 지역 변수의 4가지 변수를 정의할 수 있습니다.

다음은 모든 변수 선언이 동일한 이름을 갖지 않는다는 가정하에 함수 범위 관점에서 서로 다른 변수만을 분석합니다.

함수 외부에서 선언된 전역 변수(예: int gVar;). 전역 변수는 모든 함수에서 공유됩니다. 이 변수 ​​이름이 나타나는 모든 곳은 이 변수 ​​

를 나타냅니다. 정적 전역 변수(정적 sgVar)는 실제로 모든 함수에서 공유되지만 이는 컴파일러 제한 사항이 있으며 컴파일 A로 간주됩니다. 공유되지 않는

지역 변수(int var; 함수/블록 내)에서 제공되는 함수입니다. 함수의 여러 실행에 관련된 변수는 서로 독립적입니다. 🎜>

로컬 정적 변수(함수 내 static int sVar;)는 이 함수 간에 공유됩니다. 각 함수 실행에 관련된 변수는 동일한 변수입니다

위의 여러 범위는 모두에서 정의됩니다. 단일 스레드 프로그래밍에서 변수 공유 상황을 모두 충족할 수 있는 함수의 관점입니다. 이제 멀티스레딩 상황을 분석해 보겠습니다. 멀티스레딩에서는 여러 스레드가 함수 호출 스택 이외의 리소스를 공유합니다. 따라서 위의 범위는 정의 관점에서 나온 것입니다.

모든 함수에서 공유되는 전역 변수는 모든 스레드에서 공유되며, 다른 스레드에 나타나는 다른 변수는 동일한 변수입니다.

모든 함수에서 공유되고 또한 모두에서 공유되는 정적 전역 변수 스레드

로컬 변수, 이 함수의 각 실행에 관련된 변수는 관련이 없으므로 스레드 간에 공유되지 않습니다.

이 함수 간에 공유되는 정적 로컬 변수는 함수입니다. 각 실행에 관여하는 변수는 동일하므로 각 스레드는 공유됩니다

1. TSRM의 유래

멀티 스레드 시스템에서는 프로세스가 리소스 소유권 속성을 유지합니다. 동시 실행 스트림은 프로세스에서 실행되는 스레드입니다. 예를 들어 Apache2 작업자에서 기본 제어 프로세스는 여러 하위 프로세스를 생성하고, 각 하위 프로세스에는 고정된 수의 스레드가 포함되며, 각 스레드는 요청을 독립적으로 처리합니다. 마찬가지로 요청이 들어올 때 스레드를 생성하지 않기 위해 MinSpareThreads 및 MaxSpareThreads는 유휴 스레드의 최소 및 최대 수를 설정하고 MaxClients는 모든 하위 프로세스의 총 스레드 수를 설정합니다. 기존 하위 프로세스의 총 스레드 수가 로드를 충족할 수 없는 경우 제어 프로세스는 새 하위 프로세스를 생성합니다.

PHP가 위와 같은 멀티 스레드 서버에서 실행되는 경우 현재 PHP는 멀티 스레드 수명 주기에 있습니다. 특정 기간 내에 프로세스 공간에는 여러 스레드가 있습니다. 동일한 프로세스의 여러 스레드는 모듈 초기화 후 전역 변수를 공유합니다. CLI 모드에서 PHP와 같은 스크립트를 실행하면 여러 스레드가 읽기를 시도합니다. 프로세스 메모리 공간에 저장된 일부 공통 리소스(예: 여러 스레드에 공통된 모듈 초기화 후 함수 외부에 더 많은 전역 변수가 있게 됩니다)

이 때 액세스되는 메모리 주소 공간은 다음과 같습니다. 하나의 스레드가 수정되면 다른 스레드에 영향을 미치게 됩니다. 이렇게 공유하면 일부 작업의 속도가 향상되지만 여러 스레드 간의 결합이 더 커지며 여러 스레드가 동시에 실행되면 일반적인 데이터 일관성 문제가 발생합니다. 또는 다중 실행 결과와 단일 스레드 실행 결과가 다른 등 리소스 경쟁 및 기타 일반적인 동시성 문제가 발생합니다. 각 스레드의 전역 변수 및 정적 변수에 대한 읽기 작업만 있고 쓰기 작업이 없는 경우 이러한 전역 변수는 스레드로부터 안전하지만 이 상황은 현실적이지 않습니다.

스레드 동시성 문제를 해결하기 위해 PHP는 TSRM: Thread Safe Resource Manager를 도입했습니다. TRSM의 구현 코드는 PHP 소스 코드의 /TSRM 디렉토리에 있으며 호출은 어디에서나 찾을 수 있습니다. 일반적으로 이를 TSRM 레이어라고 부릅니다. 일반적으로 TSRM 계층은 필요하다고 지정된 경우(예: Apache2+worker MPM, 스레드 기반 MPM) 컴파일 타임에만 활성화됩니다. Win32의 Apache는 멀티스레딩을 기반으로 하기 때문입니다. Win32에서는 항상 활성화되어 있습니다.

2. TSRM 구현

프로세스는 리소스 소유권 속성을 유지하고 스레드는 동시 액세스를 수행합니다. PHP에 도입된 TSRM 계층은 공유 리소스에 대한 액세스에 중점을 둡니다. 프로세스의 메모리 공간에 존재하며 스레드 간에 공유되는 전역 변수입니다. PHP가 단일 프로세스 모드에 있을 때 변수는 함수 외부에서 선언되면 전역 변수가 됩니다.

먼저 다음과 같은 매우 중요한 전역 변수를 정의합니다(여기서 전역 변수는 여러 스레드에서 공유됩니다).

/* The memory manager table */
static tsrm_tls_entry   **tsrm_tls_table=NULL;
static int              tsrm_tls_table_size;
static ts_rsrc_id       id_count;
 
/* The resource sizes table */
static tsrm_resource_type   *resource_types_table=NULL;
static int                  resource_types_table_size;
로그인 후 복사
**각 스레드의 tsrm_tls_entry 연결 목록을 저장하는 데 사용되는 tsrm_tls_table 스레드 안전 리소스 관리자 스레드 로컬 스토리지 테이블의 전체 철자입니다.

tsrm_tls_table_size는 **tsrm_tls_table의 크기를 나타내는 데 사용됩니다.

id_count 作为全局变量资源的 id 生成器,是全局唯一且递增的。

*resource_types_table 用来存放全局变量对应的资源。

resource_types_table_size 表示 *resource_types_table 的大小。

其中涉及到两个关键的数据结构 tsrm_tls_entry 和 tsrm_resource_type。

typedef struct _tsrm_tls_entry tsrm_tls_entry;
 
struct _tsrm_tls_entry {
    void **storage;// 本节点的全局变量数组
    int count;// 本节点全局变量数
    THREAD_T thread_id;// 本节点对应的线程 ID
    tsrm_tls_entry *next;// 下一个节点的指针
};
 
typedef struct {
    size_t size;// 被定义的全局变量结构体的大小
    ts_allocate_ctor ctor;// 被定义的全局变量的构造方法指针
    ts_allocate_dtor dtor;// 被定义的全局变量的析构方法指针
    int done;
} tsrm_resource_type;
로그인 후 복사

当新增一个全局变量时,id_count 会自增1(加上线程互斥锁)。然后根据全局变量需要的内存、构造函数、析构函数生成对应的资源tsrm_resource_type,存入 *resource_types_table,再根据该资源,为每个线程的所有tsrm_tls_entry节点添加其对应的全局变量。

有了这个大致的了解,下面通过仔细分析 TSRM 环境的初始化和资源 ID 的分配来理解这一完整的过程。

TSRM 环境的初始化

模块初始化阶段,在各个 SAPI main 函数中通过调用 tsrm_startup 来初始化 TSRM 环境。tsrm_startup 函数会传入两个非常重要的参数,一个是 expected_threads,表示预期的线程数, 一个是 expected_resources,表示预期的资源数。不同的 SAPI 有不同的初始化值,比如mod_php5,cgi 这些都是一个线程一个资源。

TSRM_API int tsrm_startup(int expected_threads, int expected_resources, int debug_level, char *debug_filename)
{
    /* code... */
 
    tsrm_tls_table_size = expected_threads; // SAPI 初始化时预计分配的线程数,一般都为1
 
    tsrm_tls_table = (tsrm_tls_entry **) calloc(tsrm_tls_table_size, sizeof(tsrm_tls_entry *));
 
    /* code... */
 
    id_count=0;
 
    resource_types_table_size = expected_resources; // SAPI 初始化时预先分配的资源表大小,一般也为1
 
    resource_types_table = (tsrm_resource_type *) calloc(resource_types_table_size, sizeof(tsrm_resource_type));
 
    /* code... */
 
    return 1;
}
로그인 후 복사

精简出其中完成的三个重要的工作,初始化了 tsrm_tls_table 链表、resource_types_table 数组,以及 id_count。而这三个全局变量是所有线程共享的,实现了线程间的内存管理的一致性。

资源 ID 的分配

我们知道初始化一个全局变量时需要使用 ZEND_INIT_MODULE_GLOBALS 宏(下面的数组扩展的例子中会有说明),而其实际则是调用的 ts_allocate_id 函数在多线程环境下申请一个全局变量,然后返回分配的资源 ID。代码虽然比较多,实际还是比较清晰,下面附带注解进行说明:

TSRM_API ts_rsrc_id ts_allocate_id(ts_rsrc_id *rsrc_id, size_t size, ts_allocate_ctor ctor, ts_allocate_dtor dtor)
{
    int i;
 
    TSRM_ERROR((TSRM_ERROR_LEVEL_CORE, "Obtaining a new resource id, %d bytes", size));
 
    // 加上多线程互斥锁
    tsrm_mutex_lock(tsmm_mutex);
 
    /* obtain a resource id */
    *rsrc_id = TSRM_SHUFFLE_RSRC_ID(id_count++); // 全局静态变量 id_count 加 1
    TSRM_ERROR((TSRM_ERROR_LEVEL_CORE, "Obtained resource id %d", *rsrc_id));
 
    /* store the new resource type in the resource sizes table */
    // 因为 resource_types_table_size 是有初始值的(expected_resources),所以不一定每次都要扩充内存
    if (resource_types_table_size < id_count) {
        resource_types_table = (tsrm_resource_type *) realloc(resource_types_table, sizeof(tsrm_resource_type)*id_count);
        if (!resource_types_table) {
            tsrm_mutex_unlock(tsmm_mutex);
            TSRM_ERROR((TSRM_ERROR_LEVEL_ERROR, "Unable to allocate storage for resource"));
            *rsrc_id = 0;
            return 0;
        }
        resource_types_table_size = id_count;
    }
 
    // 将全局变量结构体的大小、构造函数和析构函数都存入 tsrm_resource_type 的数组 resource_types_table 中
    resource_types_table[TSRM_UNSHUFFLE_RSRC_ID(*rsrc_id)].size = size;
    resource_types_table[TSRM_UNSHUFFLE_RSRC_ID(*rsrc_id)].ctor = ctor;
    resource_types_table[TSRM_UNSHUFFLE_RSRC_ID(*rsrc_id)].dtor = dtor;
    resource_types_table[TSRM_UNSHUFFLE_RSRC_ID(*rsrc_id)].done = 0;
 
    /* enlarge the arrays for the already active threads */
    // PHP内核会接着遍历所有线程为每一个线程的 tsrm_tls_entry
    for (i=0; i<tsrm_tls_table_size; i++) {
        tsrm_tls_entry *p = tsrm_tls_table[i];
 
        while (p) {
            if (p->count < id_count) {
                int j;
 
                p->storage = (void *) realloc(p->storage, sizeof(void *)*id_count);
                for (j=p->count; j<id_count; j++) {
                    // 在该线程中为全局变量分配需要的内存空间
                    p->storage[j] = (void *) malloc(resource_types_table[j].size);
                    if (resource_types_table[j].ctor) {
                        // 最后对 p->storage[j] 地址存放的全局变量进行初始化,
                        // 这里 ts_allocate_ctor 函数的第二个参数不知道为什么预留,整个项目中实际都未用到过,对比PHP7发现第二个参数也的确已经移除了
                        resource_types_table[j].ctor(p->storage[j], &p->storage);
                    }
                }
                p->count = id_count;
            }
            p = p->next;
        }
    }
 
    // 取消线程互斥锁
    tsrm_mutex_unlock(tsmm_mutex);
 
    TSRM_ERROR((TSRM_ERROR_LEVEL_CORE, "Successfully allocated new resource id %d", *rsrc_id));
    return *rsrc_id;
}
로그인 후 복사

当通过 ts_allocate_id 函数分配全局资源 ID 时,PHP 内核会先加上互斥锁,确保生成的资源 ID 的唯一,这里锁的作用是在时间维度将并发的内容变成串行,因为并发的根本问题就是时间的问题。当加锁以后,id_count 自增,生成一个资源 ID,生成资源 ID 后,就会给当前资源 ID 分配存储的位置, 每一个资源都会存储在 resource_types_table 中,当一个新的资源被分配时,就会创建一个 tsrm_resource_type。 所有 tsrm_resource_type 以数组的方式组成 tsrm_resource_table,其下标就是这个资源的 ID。 其实我们可以将 tsrm_resource_table 看做一个 HASH 表,key 是资源 ID,value 是 tsrm_resource_type 结构(任何一个数组都可以看作一个 HASH 表,如果数组的key 值有意义的话)。

在分配了资源 ID 后,PHP 内核会接着遍历所有线程为每一个线程的 tsrm_tls_entry 分配这个线程全局变量需要的内存空间。 这里每个线程全局变量的大小在各自的调用处指定(也就是全局变量结构体的大小)。最后对地址存放的全局变量进行初始化。为此我画了一张图予以说明

PHP 소스 코드 학습 스레드 안전

上图中还有一个困惑的地方,tsrm_tls_table 的元素是如何添加的,链表是如何实现的。我们把这个问题先留着,后面会讨论。

每一次的 ts_allocate_id 调用,PHP 内核都会遍历所有线程并为每一个线程分配相应资源, 如果这个操作是在PHP生命周期的请求处理阶段进行,岂不是会重复调用?

PHP 考虑了这种情况,ts_allocate_id 的调用在模块初始化时就调用了。

TSRM 启动后,在模块初始化过程中会遍历每个扩展的模块初始化方法, 扩展的全局变量在扩展的实现代码开头声明,在 MINIT 方法中初始化。 其在初始化时会知会 TSRM 申请的全局变量以及大小,这里所谓的知会操作其实就是前面所说的 ts_allocate_id 函数。 TSRM 在内存池中分配并注册,然后将资源ID返回给扩展。

全局变量的使用

以标准的数组扩展为例,首先会声明当前扩展的全局变量。

ZEND_DECLARE_MODULE_GLOBALS(array)
로그인 후 복사

然后在模块初始化时会调用全局变量初始化宏初始化 array,比如分配内存空间操作。

static void php_array_init_globals(zend_array_globals *array_globals)
{
    memset(array_globals, 0, sizeof(zend_array_globals));
}
 
/* code... */
 
PHP_MINIT_FUNCTION(array) /* {{{ */
{
    ZEND_INIT_MODULE_GLOBALS(array, php_array_init_globals, NULL);
    /* code... */
}
로그인 후 복사

这里的声明和初始化操作都是区分ZTS和非ZTS。

#ifdef ZTS
 
#define ZEND_DECLARE_MODULE_GLOBALS(module_name)                            \
    ts_rsrc_id module_name##_globals_id;
 
#define ZEND_INIT_MODULE_GLOBALS(module_name, globals_ctor, globals_dtor)   \
    ts_allocate_id(&module_name##_globals_id, sizeof(zend_##module_name##_globals), (ts_allocate_ctor) globals_ctor, (ts_allocate_dtor) globals_dtor);
 
#else
 
#define ZEND_DECLARE_MODULE_GLOBALS(module_name)                            \
    zend_##module_name##_globals module_name##_globals;
 
#define ZEND_INIT_MODULE_GLOBALS(module_name, globals_ctor, globals_dtor)   \
    globals_ctor(&module_name##_globals);
 
#endif
로그인 후 복사

对于非ZTS的情况,直接声明变量,初始化变量;对于ZTS情况,PHP内核会添加TSRM,不再是声明全局变量,而是用ts_rsrc_id代替,初始化时也不再是初始化变量,而是调用ts_allocate_id函数在多线程环境中给当前这个模块申请一个全局变量并返回资源ID。其中,资源ID变量名由模块名加global_id组成。

如果要调用当前扩展的全局变量,则使用:ARRAYG(v),这个宏的定义:

#ifdef ZTS
#define ARRAYG(v) TSRMG(array_globals_id, zend_array_globals *, v)
#else
#define ARRAYG(v) (array_globals.v)
#endif
로그인 후 복사

如果是非ZTS则直接调用全局变量的属性字段,如果是ZTS,则需要通过TSRMG获取变量。

TSRMG的定义:

#define TSRMG(id, type, element) (((type) (*((void ***) tsrm_ls))[TSRM_UNSHUFFLE_RSRC_ID(id)])->element)
로그인 후 복사

去掉这一堆括号,TSRMG宏的意思就是从tsrm_ls中按资源ID获取全局变量,并返回对应变量的属性字段。

那么现在的问题是这个 tsrm_ls 从哪里来的?

tsrm_ls 的初始化

tsrm_ls 通过 ts_resource(0) 初始化。展开实际最后调用的是 ts_resource_ex(0,NULL) 。下面将 ts_resource_ex 一些宏展开,线程以 pthread 为例。

#define THREAD_HASH_OF(thr,ts)  (unsigned long)thr%(unsigned long)ts
 
static MUTEX_T tsmm_mutex;
 
void *ts_resource_ex(ts_rsrc_id id, THREAD_T *th_id)
{
    THREAD_T thread_id;
    int hash_value;
    tsrm_tls_entry *thread_resources;
 
    // tsrm_tls_table 在 tsrm_startup 已初始化完毕
    if(tsrm_tls_table) {
        // 初始化时 th_id = NULL;
        if (!th_id) {
 
            //第一次为空 还未执行过 pthread_setspecific 所以 thread_resources 指针为空
            thread_resources = pthread_getspecific(tls_key);
 
            if(thread_resources){
                TSRM_SAFE_RETURN_RSRC(thread_resources->storage, id, thread_resources->count);
            }
 
            thread_id = pthread_self();
        } else {
            thread_id = *th_id;
        }
    }
    // 上锁
    pthread_mutex_lock(tsmm_mutex);
 
    // 直接取余,将其值作为数组下标,将不同的线程散列分布在 tsrm_tls_table 中
    hash_value = THREAD_HASH_OF(thread_id, tsrm_tls_table_size);
    // 在 SAPI 调用 tsrm_startup 之后,tsrm_tls_table_size = expected_threads
    thread_resources = tsrm_tls_table[hash_value];
 
    if (!thread_resources) {
        // 如果还没,则新分配。
        allocate_new_resource(&tsrm_tls_table[hash_value], thread_id);
        // 分配完毕之后再执行到下面的 else 区间
        return ts_resource_ex(id, &thread_id);
    } else {
         do {
            // 沿着链表逐个匹配
            if (thread_resources->thread_id == thread_id) {
                break;
            }
            if (thread_resources->next) {
                thread_resources = thread_resources->next;
            } else {
                // 链表的尽头仍然没有找到,则新分配,接到链表的末尾
                allocate_new_resource(&thread_resources->next, thread_id);
                return ts_resource_ex(id, &thread_id);
            }
         } while (thread_resources);
    }
 
    TSRM_SAFE_RETURN_RSRC(thread_resources->storage, id, thread_resources->count);
 
    // 解锁
    pthread_mutex_unlock(tsmm_mutex);
 
}
로그인 후 복사

而 allocate_new_resource 则是为新的线程在对应的链表中分配内存,并且将所有的全局变量都加入到其 storage 指针数组中。

static void allocate_new_resource(tsrm_tls_entry **thread_resources_ptr, THREAD_T thread_id)
{
    int i;
 
    (*thread_resources_ptr) = (tsrm_tls_entry *) malloc(sizeof(tsrm_tls_entry));
    (*thread_resources_ptr)->storage = (void **) malloc(sizeof(void *)*id_count);
    (*thread_resources_ptr)->count = id_count;
    (*thread_resources_ptr)->thread_id = thread_id;
    (*thread_resources_ptr)->next = NULL;
 
    // 设置线程本地存储变量。在这里设置之后,再到 ts_resource_ex 里取
    pthread_setspecific(*thread_resources_ptr);
 
    if (tsrm_new_thread_begin_handler) {
        tsrm_new_thread_begin_handler(thread_id, &((*thread_resources_ptr)->storage));
    }
 
    for (i=0; i<id_count; i++) {
        if (resource_types_table[i].done) {
            (*thread_resources_ptr)->storage[i] = NULL;
        } else {
            // 为新增的 tsrm_tls_entry 节点添加 resource_types_table 的资源
            (*thread_resources_ptr)->storage[i] = (void *) malloc(resource_types_table[i].size);
            if (resource_types_table[i].ctor) {
                resource_types_table[i].ctor((*thread_resources_ptr)->storage[i], &(*thread_resources_ptr)->storage);
            }
        }
    }
 
    if (tsrm_new_thread_end_handler) {
        tsrm_new_thread_end_handler(thread_id, &((*thread_resources_ptr)->storage));
    }
 
    pthread_mutex_unlock(tsmm_mutex);
}
로그인 후 복사

上面有一个知识点,Thread Local Storage ,现在有一全局变量 tls_key,所有线程都可以使用它,改变它的值。 表面上看起来这是一个全局变量,所有线程都可以使用它,而它的值在每一个线程中又是单独存储的。这就是线程本地存储的意义。 那么如何实现线程本地存储呢?

需要联合 tsrm_startup, ts_resource_ex, allocate_new_resource 函数并配以注释一起举例说明:

// 以 pthread 为例
// 1. 首先定义了 tls_key 全局变量
static pthread_key_t tls_key;
 
// 2. 然后在 tsrm_startup 调用 pthread_key_create() 来创建该变量
pthread_key_create( &tls_key, 0 );
 
// 3. 在 allocate_new_resource 中通过 tsrm_tls_set 将 *thread_resources_ptr 指针变量存入了全局变量 tls_key 中
tsrm_tls_set(*thread_resources_ptr);// 展开之后为 pthread_setspecific(*thread_resources_ptr);
 
// 4. 在 ts_resource_ex 中通过 tsrm_tls_get() 获取在该线程中设置的 *thread_resources_ptr
//    多线程并发操作时,相互不会影响。
thread_resources = tsrm_tls_get();
로그인 후 복사

在理解了 tsrm_tls_table 数组和其中链表的创建之后,再看 ts_resource_ex 函数中调用的这个返回宏

#define TSRM_SAFE_RETURN_RSRC(array, offset, range)     \
    if (offset==0) {                                    \
        return &array;                                  \
    } else {                                            \
        return array[TSRM_UNSHUFFLE_RSRC_ID(offset)];   \
    }
로그인 후 복사

就是根据传入 tsrm_tls_entry 和 storage 的数组下标 offset ,然后返回该全局变量在该线程的 storage数组中的地址。到这里就明白了在多线程中获取全局变量宏 TSRMG 宏定义了。

其实这在我们写扩展的时候会经常用到:

#define TSRMLS_D void ***tsrm_ls   /* 不带逗号,一般是唯一参数的时候,定义时用 */
#define TSRMLS_DC , TSRMLS_D       /* 也是定义时用,不过参数前面有其他参数,所以需要个逗号 */
#define TSRMLS_C tsrm_ls
#define TSRMLS_CC , TSRMLS_C
로그인 후 복사

NOTICE 写扩展的时候可能很多同学都分不清楚到底用哪一个,通过宏展开我们可以看到,他们分别是带逗号和不带逗号,以及申明及调用,那么英语中“D"就是代表:Define,而 后面的"C"是 Comma,逗号,前面的"C"就是Call。

以上为ZTS模式下的定义,非ZTS模式下其定义全部为空。

参考资料

究竟什么是TSRMLS_CC?- 54chen

深入研究PHP及Zend Engine的线程安全模型


원천:php.cn
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 추천
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿
회사 소개 부인 성명 Sitemap
PHP 중국어 웹사이트:공공복지 온라인 PHP 교육,PHP 학습자의 빠른 성장을 도와주세요!