4.4 线程安全
单线程环境中,我们经常使用全局变量实现多个函数间共享数据,声明在函数之外的变量为全局变量,全局变量为各线程共享,不同的线程引用同一地址空间,如果一个线程修改了全局变量就会影响所有线程。线程安全指的是多线程环境下如何安全地获取公共资源。
PHP的SAPI多数是单线程环境,比如Cli、Fpm、Cgi,每个进程只启动一个主线程,这种模式下不存在线程安全问题,但也有多线程的环境,比如Apache,或用户自己嵌入PHP实现的环境,此时就要考虑线程安全问题了,因为PHP中使用了很多全局变量,经常使用的EG、CG等宏就是用来获取公共资源的。在多线程环境下使用全局变量,会引起线程之间的冲突,因此,PHP实现了一个线程安全资源管理器(Thread Safe Resource Manager,TSRM),用于解决多线程环境下公共资源冲突的问题,实现线程之间安全地操作公共资源。
4.4.1 TSRM的基本实现
TSRM相关的实现代码位于PHP源码的TSRM目录下,它的实现思路比较简单:既然共用资源这么困难,那么就干脆不共用,各线程不再共享同一份全局变量,而是各复制一份,使用数据时各线程各取自己的副本,互不干扰。比如,有个整型的公共资源global_num,在单线程环境下直接分配一个全局变量即可,但在多线程环境下就需要进行分离,假如有3个线程,那么就会分配三份global_num,线程之间互不干扰,如图4-16所示:
TSRM的核心思想就是为不同线程分配独立的内存空间,如果一个资源会被多线程使用,那么就需要向TSRM注册资源,TSRM会为这个资源分配一个唯一id,并把这种资源的大小、初始化函数等保存到一个tsrm_resource_type结构中,各线程只能通过TSRM分配的id访问这个资源。当线程拿着资源id向TSRM获取资源时,TSRM如果发现是第一次请求,则会根据资源大小分配一块内存,然后调用初始化函数进行初始化,并把这块资源保存下来供这个线程后续使用。
TSRM为每个线程分配一个tsrm_tls_entry结构,该结构用于保存所有的公共资源:
typedef struct _tsrm_tls_entry tsrm_tls_entry;
struct _tsrm_tls_entry {
void **storage; // 公共资源数组
int count; // 公共资源数,即storage数组大小
THREAD_T thread_id; // 所属线程id
tsrm_tls_entry *next; // 线程之间构成链表
};
所有线程的tsrm_tls_entry结构保存在tsrm_tls_table数组中,该数组是一个全局变量,操作这个变量时需要加锁。tsrm_tls_entry在TSRM初始化时按照预设值的线程数分配,每个线程的tsrm_tls_entry结构在这个数组中的位置是根据线程id与预设值的线程数(tsrm_tls_table_size)取模得到的。也就是说,可能多个线程保存在tsrm_tls_table的同一位置,所以tsrm_tls_entry是一个链表。线程在查找自己的tsrm_tls_entry时,首先根据thread_id % tsrm_tls_table_size
得到一个tsrm_tls_entry,然后需要遍历链表比较thread_id找到当前线程的链表节点。比如tsrm_tls_table的大小设为2,现在有3个thread,则存储结构如图4-17所示:
公共资源使用前必须向TSRM注册,注册时需要提供以下信息:资源大小、资源初始化函数、资源清理函数。TSRM会为注册的资源分配一个tsrm_resource_type结构来保存这些信息,当有新线程创建时,就会根据这些信息进行资源分配与初始化:
typedef struct {
size_t size; // 资源的大小
ts_allocate_ctor ctor; // 初始化函数
ts_allocate_dtor dtor;
int done;
} tsrm_resource_type;
所有资源的tsrm_resource_type结构保存在resource_types_table数组中,该数组在资源注册时会进行扩容。同时,注册后TSRM会为资源分配一个唯一id,这个资源id保存到全局变量即可,各线程根据同一个资源id向TSRM获取各自线程的资源。比如当前注册了两个资源:global_array_id、global_num_id,资源类型分别为zend_array、uint32_t,则对应的resource_types_table如图4-18所示:
TSRM只会根据size大小分配对应的内存,但并不清楚这块内存具体存储什么类型,所以需要在注册时指定初始化函数ctor,TSRM在分配完内存后会调用该函数进行内存的初始化。
4.4.1.1 TSRM初始化
在使用TSRM前需要主动开启它,这个步骤在SAPI启动时完成,主要工作是分配tsrm_tls_table、resource_types_table内存,以及创建线程互斥锁。
TSRM_API int tsrm_startup(int expected_threads, int expected_resources, int debug_level,
char *debug_filename) {
pthread_key_create(&tls_key, 0);
// 分配tsrm_tls_table
tsrm_tls_table_size = expected_threads;
tsrm_tls_table = (tsrm_tls_entry **)calloc(tsrm_tls_table_size,
sizeof(tsrm_tls_entry));
...
// 初始化资源的递增id,用于资源id的分配
id_count = 0;
// 分配资源类型数组:resource_types_table
resource_types_table_size = expected_resources;
resource_types_table = (tsrm_resource_type *)calloc(resource_types_table_size,
sizeof(tsrm_resource_type));
...
// 创建锁
tsmm_mutex = tsrm_mutex_alloc();
}
4.4.1.2 注册资源
TSRM初始化后各模块就可以进行资源注册了,注册后TSRM会给资源分配一个唯一的资源id,之后对此资源的操作只能依据此id,接下来以zend_executor_globals这个全局符号表为例看一下其注册过程:
// file: zend_globals.h
#ifdef ZTS
ZEND_API int executor_globals_id;
#endif
// file: zend.c
int zend_startup(zend_utility_functions *utility_functions, char **extensions) {
...
#ifdef ZTS
// 注册资源
ts_allocate_id(&executor_globals_id, sizeof(zend_executor_globals),
(ts_allocate_ctor)executor_globals_ctor, (ts_allocate_dtor)executor_globals_dtor);
...
#endif
}
资源通过ts_allocate_id()完成注册:
TSRM_API ts_rsrc_id ts_allocate_id(ts_rsrc_id *rsrc_id, size_t size, ts_allocate_ctor ctor,
ts_allcate_dtor dtor) {
// 加锁,保证各线程原子地执行此函数
tsrm_mutex_lock(tsmm_mutex);
// 分配资源id,即id_count当前值,然后把id_count加1
*rsrc_id = TSRM_SHUFFLE_RSRC_ID(id_count++);
// 检查resource_types_table数组当前大小是否已满
if (resource_types_table_size < id_count) {
// 需要对resource_types_table扩容
resource_types_table = (tsrm_resource_type *)realloc(resource_types_table,
sizeof(tsrm_resource_type) * id_count);
...
// 把数组大小改为新的大小
resource_types_table_size = id_count;
}
// 将新注册的资源插入resource_types_table数组,下标就是分配的资源id
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;
...
}
此时资源注册还没有完成,新注册一个资源时,如果线程tsrm_tls_entry结构中用于存储资源的storage数组没有空闲容纳新资源,就需要对那些storage数组已满的线程进行扩容。扩容过程:遍历各线程的tsrm_tls_entry,检查storege是否有空闲空间,有的话跳过,没有的话则扩容。
// ts_allocate_id():
for (i = 0; i < tsrm_tls_table_size; i++) {
tsrm_tls_entry *p = tsrm_tls_table[i];
// tsrm_tls_table[i]可能保存着多个线程,需要遍历链表
while (p) {
// 如果storage数组大小小于资源数数量,就有可能出现storage数组已满
if (p->count < id_count) {
int j;
// 将storage扩容
p->storage = (void *)realloc(p->storate, 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) {
// 回调初始化函数进行初始化
resource_types_table[j].ctor(p->storage[j]);
}
}
p->count = id_count;
}
p = p->next;
}
}
// 解锁
tsrm_mutex_unlock(tsmm_mutex);
return *rsrc_id;
4.4.1.3 获取资源
各线程根据资源id通过ts_resource()方法获取对应的资源:
#define ts_resource(id) ts_resource_ex(id, NULL)
比如注册的zend_executor_globals这个资源,就可通过如下方式获取,取到的executor_globals就是属于线程自己的内存结构了:
zend_executor_globals *executor_globals;
executor_globals = ts_resource(executor_globals_id);
接下来看一下资源的获取过程:
1.获取线程的tsrm_tls_entry结构
首先是获取线程id,如果是pthread则可通过pthread_self()方法获取;然后根据线程id从tsrm_tls_table数组中获取tsrm_tls_entry,这里需要对取到的tsrm_tls_entry遍历,比较线程id,以找到该线程的tsrm_tls_entry,如果没有找到则表示线程还未分配tsrm_tls_entry,进入第2步进行分配,如果找到了则表示已经分配了tsrm_tls_entry,进入第3步返回storage中对应的资源。
// ts_resource_ex():
THREAD_T thread_id;
int hash_value;
tsrm_tls_entry *thread_resources;
// 获取线程id
if (!th_id) {
// 查询线程本地存储,暂时忽略
...
thread_id = tsrm_thread_id(); // pthread_self(),当前线程id
} else {
thread_id = *th_id;
}
tsrm_mutex_lock(tsmm_mutex);
// 获取在tsrm_tls_table数组中的存储位置,实际就是thread_id % tsrm_tls_table_size
hash_value = THREAD_HASH_OF(thread_id, tsrm_tls_table_size);
thread_resources = tsrm_tls_table[hash_value];
if (!thread_resources) {
// 线程的资源还没分配
// 进入第2步分配资源,然后重新调用本函数
...
return ts_resource_ex(id, &thread_id);
} else {
// 遍历查找当前线程的tsrm_tls_entry
do {
// 找到了,终端查找,进入第3步
if (thread_resources->thread_id == thread_id) {
break;
}
if (thread_resources->next) {
// 继续向后遍历
thread_resources = thread_resources->next;
} else {
// 遍历到最后也没找到,与上面的一致,进入第2步分配资源,然后重新调用本函数
...
return ts_resource_ex(id, &thread_id);
}
} while (thread_resources);
}
2.分配线程资源
查找线程的tsrm_tls_entry,如果没有找到,则需要进行资源分配,这一步通过allocate_new_resource()完成,分配后会把新分配的tsrm_tls_entry插入tsrm_tls_table中对应链表的末尾,同时为所有资源分配内存空间。注意:tsrm_tls_entry->storage保存资源的地址,并不是资源的内存空间。最后,根据注册的资源数以及各资源注册时的配置tsrm_resource_type进行内存分配,分配完成后线程就可以根据资源id获取本线程的资源了。
static void allocate_new_resource(tsrm_tls_entry **thread_resources_ptr,
THREAD_T thread_id) {
// thread_resources_ptr为tsrm_tls_table对应链表末尾的地址,也就是新插入tsrm_tls_entry的地址
// 分配tsrm_tls_entry结构
(*thread_resources_ptr) = (tsrm_tls_entry *)malloc(sizeof(tsrm_tls_entry));
(*thread_resources_ptr)->storage = NULL;
// 根据已注册资源数分配用于保存资源地址的storage数组,注意这里并不是分配资源空间
if (id_count > 0) {
(*thraed_resources_ptr)->storage = (void **)malloc(sizeof(void *) * id_count);
}
(*thread_resources_ptr)->count = id_count;
(*thread_resources_ptr)->thread_id = thread_id;
// 将当前线程的tsrm_tls_entry保存到线程本地存储,暂时忽略,稍后说明
tsrm_tls_set(*thread_resources_ptr);
// 为全部资源分配空间
for (i = 0; i < id_count; i++) {
...
// 根据资源tsrm_resource_type获取资源的内存大小
(*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]);
}
}
...
}
3.根据资源id获取资源
线程资源只能通过资源id获取,这一步直接根据资源id从tsrm_tls_entry->storage数组中获取资源地址即可:
// id就是资源id
TSRM_SAFE_RETURN_RSRC(thread_resources->storage, id, thread_resources->count);
TSRM_SAFE_RETURN_RSRC()展开后就是tsrm_tls_entry->storage[id - 1]
。
4.4.2 线程私有数据
上一节介绍了获取资源的过程,主要分为三步:
1.获取线程id。
2.根据线程id查找tsrm_tls_entry,这个过程需要加锁、遍历,比较耗时。
3.根据资源id从tsrm_tls_entry->storage数组中获取资源。
可见整体流程比较长,而且第2步需要加锁,同一时刻只允许一个线程进行。另外,资源的访问又是非常频繁的动作,如果每次获取都经历这三步,将严重影响性能,这是不可接受的。
TSRM通过线程私有数据(Thread-Specific Data,TSD)优化了这个问题,TSD是由POSIX线程库维护的,使用同一名称为不同线程保存数据的一种存储形式,即各线程可根据同名的key获取到不同的变量地址。GNU C Library定义了以下函数用于TSD的操作:
// 创建名为key的TSD
int pthread_key_create(pthread_key_t *key, void (*destructor)(void *));
// 销毁TSD
int pthread_key_delete(pthread_key_t key);
// 根据key设置TSD
int pthread_setspecific(pthread_key_t key, const void *value);
// 根据key获取
void *pthread_getspecific(pthread_key_t key);
TSRM在分配线程资源时(allocate_new_resource()函数)将各线程tsrm_tls_entry的地址保存到TSD中,然后再ts_resource_ex()获取资源时从TSD中取出,如图4-20所示:
static pthread_key_t tls_key;
#define tsrm_tls_set(what) pthread_setspecific(tls_key, (void *)(what))
#define tsrm_tls_get() pthread_getspecific(tls_key)
static void allocate_new_resource(tsrm_tls_entry **thread_resources_ptr,
THREAD_T thread_id) {
...
// 将当前线程的tsrm_tls_entry地址保存到TLS
tsrm_tls_set(*thread_resources_ptr);
...
}
TSRM_API void *ts_resource_ex(ts_rsrc_id id, THREAD_T *th_id) {
...
if (!th_id) {
// 从TLS获取tsrm_tls_entry地址
thread_resources = tsrm_tls_get();
...
}
...
}
为什么不直接用TSD代替TSRM呢,都是为了实现TLS的?可能TSRM更专用一点,TSD比较通用。
4.4.3 线程局部存储
通过TSD,TSRM将资源的获取缩减为以下两步:
1.调用pthread_getspecific()获取tsrm_tls_entry地址。
2.根据资源id从tsrm_tls_entry->storage获取资源。
尽管通过TSD已经大大提高了资源的查询性能,但每次获取资源仍需要调用ts_resource(),然后其中再调用pthread_getspecific(),与变量的内存读写效率相比还是差很远。根据以上步骤,实际耗时的部分是获取tsrm_tls_entry->storage数组的环节,而后面根据资源id从storage数组中取值的操作是内存直接寻址,性能上没有问题,因此,需要减少tsrm_tls_entry->storage数组的检索次数。
在介绍PHP7的实现前,先说一下PHP5中的做法。PHP5的解决方法非常简单,它通过参数传递的方式将tsrm_tls_entry->storage地址一级级地传给后面调用的函数。即第一次获取tsrm_tls_entry->storage的函数,会把它的地址作为参数传给调用的函数,然后被调用的函数接着再往下传,通过这种方式将storage传给所有函数。写过PHP5扩展的开发者对TSRMLS_DC、TSRMLS_CC(TSRMLS_D、TSRMLS_C)两个宏一定不会陌生。在扩展中定义函数时,需要在参数列表末尾加上TSRMLS_DC宏;在调用函数时,需要在传参列表末尾加上TSRMLS_CC宏。这两个宏就是用来传递tsrm_tls_entry->storage地址的:
#define TSRMLS_D void ***tsrm_ls
#define TSRMLS_C tsrm_ls
#define TSRMLS_DC , TSRMLS_D
#define TSRMLS_CC , TSRMLS_C
比如在扩展中定义test()函数,则需要这样定义:
void test(int id TSRMLS_DC) {
...
}
// 展开后:
void test(int id, void ***tsrm_ls) {
...
}
调用时:
void other_func(TSRMLC_DC) {
// call test()
test(1234 TSRMLS_CC);
}
// 展开后
void other_func(void ***tsrm_ls) {
test(1234 , tsrm_ls);
}
Fpm中,tsrm_tls_entry->storage是在main()函数中获取的,之后就一直向下传递,后面的函数直接通过参数获取到本线程的资源池。
// file: sapi/fpm/fpm/fpm_main.c
// version: php-5.6.27
int main(int argc, char *argv[]) {
...
#ifdef ZTS
void ***tsrm_ls;
#endif
...
#ifdef ZTS
tsrm_startup(1, 1, 0, NULL);
tsrm_ls = ts_resource(0);
#endif
...
}
PHP5的处理方式非常简单,而且效率也很高,但很不优雅,不管函数用不用TSRM,都得在函数中加上那两个宏,也很容易遗漏。PHP7没有沿用这种实现,而是采用线程局部存储来保存各线程的tsrm_tls_entry->storage。
线程局部存储(Thread-Local Storage,TLS)是一种为每个线程分配一份变量复制的机制,每个线程保存自己的一份副本,线程之间的变量地址是不同的。要定义一个线程局部变量只需简单地在全局或静态变量的声明中包含__thread说明符即可,需注意:一个变量声明前的__thread关键词只能独自使用或紧随static、extern之后,例如:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
__thread int num = 0;
void *worker(void *arg) {
while (1) {
printf("thread:%d %p\n", num, &num);
sleep(1);
}
}
int main(void) {
pthread_t tid;
int ret;
if ((ret = pthread_create(&tid, NULL, worker, NULL)) != 0) {
return 1;
}
while (1) {
num = 4;
printf("main:%d %p\n", num, &num);
sleep(1);
}
return 0;
}
上例中有两个线程,其中主线程修改了全局变量num,但并没有影响另一个线程,同时根据打印出的num的地址可以看出,两个线程的num具有不同的地址。
PHP中通过ZEND_TSRMLS_CACHE_DEFINE()宏定义线程局部变量,这个变量中保存的是tsrm_get_ls_cache->storage数组(这个数组是干嘛的?)中第一个元素的地址,它在sapi_startup()中完成赋值:
// TSRM/TSRM.h
#define TSRMLS_CACHE_DEFINE() TSRM_TLS void *TSRMLS_CACHE = NULL;
#define TSRMLS_CACHE_UPDATE() if (!TSRMLS_CACHE) TSRMLS_CACHE = tsrm_get_ls_cache()
展开后:
__thread void *_tsrm_ls_cache = NULL;
// 初始化
_tsrm_ls_cache = tsrm_get_ls_cache();
EG(xxx)最终展开:((zend_executor_globals)(((void **)_tsrm_ls_cache))[executor_globals_id - 1]->xxx)
。