Overview
thread_pool
ngx_thread_pool_init
- 创建的是detached类型的线程
- 有一段类似于注释掉的代码,把thread的栈大小设置为
PTHREAD_STACK_MIN
- 线程中运行的函数是
ngx_thread_pool_cycle
- 要注意的是pthread中,
main
函数退出(不是通过pthread_exit
结束,而是通过exit
或直接return
结束),那么即使还有线程没有结束,依然是程序退出。而这里并没有在main函数去等待这些线程,另一方面,这些线程是detached
线程,即无法被join
- 这里不能使用join去等待,因为这是detached的线程
- 这是一个确定大小的线程池,并且没有线程复活的机制——似乎在C中,只要不 “调用
pthread_exit
” 或者 “从pthread_create
运行的start_routine
return”,就不会出现线程失败而进程还活着的情况?所以并不需要线程复活的机制?
ngx_thread_pool_destroy
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31static void
ngx_thread_pool_destroy(ngx_thread_pool_t *tp)
{
ngx_uint_t n;
ngx_thread_task_t task;
volatile ngx_uint_t lock;
ngx_memzero(&task, sizeof(ngx_thread_task_t));
task.handler = ngx_thread_pool_exit_handler;
task.ctx = (void *) &lock;
for (n = 0; n < tp->threads; n++) {
lock = 1;
if (ngx_thread_task_post(tp, &task) != NGX_OK) {
return;
}
// 在exit的函数里会设置lock为0,
while (lock) {
ngx_sched_yield();
}
task.event.active = 0;
}
(void) ngx_thread_cond_destroy(&tp->cond, tp->log);
(void) ngx_thread_mutex_destroy(&tp->mtx, tp->log);
}这里是通过往任务队列push进exit任务来实现destroy的(该任务的handler是
ngx_thread_pool_exit_handler
)。然后不断等待lock
变量(已用volatile修饰)变为0
,才开始destroy下一个线程- 我觉得这里之所以不用一个
lock
数组,从而无需同步的等待线程退出,或许有如下原因- 用数组的实现方式大约是:不断push shutdown任务,直到所有线程都被push了shutdown任务。然后再跑一个轮询,等待lock数组里的元素都变为0,期间如果遇到非0的元素,要么停下来等待,要么收集起来,用于下一次轮询。无论是那种,逻辑都很复杂
- 关闭线程池不需要很快。线程池是非常重量的,所以不宜频繁关闭打开,那么关闭其实是一个占比很小的需求,所以简单实现下就好
- 我以前自己实现的线程池是设置一个state变量,这个变量的存活期与整个线程池的存活期一样行。这个state变量是一个atomic变量,并且被worker(在nginx中对应的就是
ngx_thread_pool_cycle
函数)不断读取——每次循环都要读取两次。从而有非常高的同步开销。这里,这个volatile变量只有在shutdown期间才存在,所以开销低非常多
ngx_thread_pool_queue_t
- 表示一个单链表,节点是
ngx_thread_task_t
类型 定义
1
2
3
4typedef struct {
ngx_thread_task_t *first;
ngx_thread_task_t **last;
} ngx_thread_pool_queue_t;last
字段存最后一个ngx_thread_task_t
类型的元素的next字段的地址,从而需要append一个元素到末尾时,只需要解引用该字段写入ngx_thread_task_t*
类型的数据即可1
2*ngx_thread_pool_done.last = task;
ngx_thread_pool_done.last = &task->next;append到单链表末尾也是O(1)的复杂度(因为我们有最末尾元素的next字段的地址)。当然,添加到头部也是O(1)的复杂度
ngx_thread_pool_cycle
流程是
- 先block掉大部分信号(除了SIGILL、SIGFPE、SIGSEGV、SIGBUS以及其他不能被忽略和捕获的信号——比如SIGKILL、SIGSTOP)
跑一个无限循环
- 在循环中加锁获取queue头部的task对象,如果无法获取,使用条件变量等待。中间如果出现错误则
return
- 获取task对象后run这个task对象的handler
- 使用spin lock加锁,加锁成功后把这个task对象放到done队列尾部
加入到队列后,会有个GCC的编译器内存屏障,使得
Added appropriate ngx_memory_barrier() calls to make sure all modifications will happen before the lock is released(来自这句statement的commit msg)
- 之后调用notify函数,传入
ngx_thread_pool_handler
函数的地址
- 在循环中加锁获取queue头部的task对象,如果无法获取,使用条件变量等待。中间如果出现错误则
里面有这么一段
1
2
3
4
5
6
7
8ngx_spinlock(&ngx_thread_pool_done_lock, 1, 2048);
*ngx_thread_pool_done.last = task;
ngx_thread_pool_done.last = &task->next;
ngx_memory_barrier();
ngx_unlock(&ngx_thread_pool_done_lock);commit msg对
ngx_memory_barrier
的解释是(注意,这里的ngx_memory_barrier
并不是CPU内存屏障,而是编译器的内存屏障)Thread pools: memory barriers in task completion notifications. The
ngx_thread_pool_done
object isn’t volatile, and at least some compilers assume that it is permitted to reorder modifications of volatile and non-volatile objects. Added appropriatengx_memory_barrier()
calls to make sure all modifications will happen before the lock is released. Reported by Mindaugas Rasiukevicius, http://mailman.nginx.org/pipermail/nginx-devel/2016-April/008160.html这里之所以不使用mutex,我认为有这几个原因
- 这里竞争不激烈,所以重量锁不必要
ngx_thread_task_alloc
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14ngx_thread_task_t *
ngx_thread_task_alloc(ngx_pool_t *pool, size_t size)
{
ngx_thread_task_t *task;
task = ngx_pcalloc(pool, sizeof(ngx_thread_task_t) + size);
if (task == NULL) {
return NULL;
}
task->ctx = task + 1;
return task;
}这个函数非常有意思,这是一个被外部调用的函数,用来获得task结构的。其把ctx(也就是具体的work函数的参数)分配在紧邻结构体的地方,从而使得结构体本身与结构体内ctx指针的内存位置连在一起,对cache非常友好
ngx_thread_task_post
- 用于往任务队列里push task对象
- 这个push是线程安全的,但是要求所有的请求排队——因为是使用thread pool的mutex来加锁几乎整个函数体的
- 这里用的是加锁、push对象,cond signal的策略。
ngx_thread_pool_cycle
的函数也是用加锁、cond wait的策略来获取任务的。这里并没有使用spin-lock加上重型锁(就是mutex)来优化。可能的原因我认为有- 需要条件变量,所以需要锁mutex——如果用spin lock,那其实无需条件变量
- 可能push任务这个需求并不是非常频繁,反而任务执行才是重点——目前nginx的线程池好像只是用于异步磁盘IO。另一方面,如果执行任务的时间比push任务的时间比起来更小,或者是相差不大,那其实并没有使用线程池的必要——直接在当前线程运行就好,因为push进线程池本身就有非常大的开销,并且线程一多,上下文切换的开销也大,从nginx的设计理念来讲,只要不是阻塞操作(比如磁盘IO是阻塞操作(读写文件似乎不能Nonblocking),网络IO可以不阻塞),都没有必要使用线程池
与我自己的线程池的对比
- 我的线程池
- nginx的优点
- ngxin不需要维护线程池的state变量(该变量是atomic的,并且被频繁读取,读取这种变量开销很大),从而同步开销小
- 线程池中需要同步的变量很少很少,并且对这些变量的操作也非常少,从而同步开销小
spin_lock
主要代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31void
ngx_spinlock(ngx_atomic_t *lock, ngx_atomic_int_t value, ngx_uint_t spin)
{
ngx_uint_t i, n;
for ( ;; ) {
if (*lock == 0 && ngx_atomic_cmp_set(lock, 0, value)) {
return;
}
if (ngx_ncpu > 1) {
for (n = 1; n < spin; n <<= 1) {
for (i = 0; i < n; i++) {
ngx_cpu_pause();
}
if (*lock == 0 && ngx_atomic_cmp_set(lock, 0, value)) {
return;
}
}
}
// 一次spin过去了还没有拿到锁,则让出cpu
ngx_sched_yield();
}
}ngx_atomic_cmp_set
会插入编译器的memory barrier,不是cpu的memory barrier(以下Ref from GCC doc)“memory”
The “memory” clobber tells the compiler that the assembly code performs memory reads or writes to items other than those listed in the input and output operands (for example, accessing the memory pointed to by one of the input parameters).
To ensure memory contains correct values, GCC may need to flush specific register values to memory before executing the asm.
Further, the compiler does not assume that any values read from memory before an asm remain unchanged after that asm; it reloads them as needed.
Using the “memory” clobber effectively forms a read/write memory barrier for the compiler.
Note that this clobber does not prevent the processor from doing speculative reads past the asm statement. To prevent that, you need processor-specific fence instructions.
flush to memory代价很高,gcc还允许一些细致的优化,见原文
1
2
3
4
5
6
7
8
9> __asm__ volatile (
>
> NGX_SMP_LOCK
> " cmpxchgq %3, %1; "
> " sete %0; "
>
> // 按照GCC内联汇编的文档,受影响列表中的`memory`会导致`Using the "memory" clobber effectively forms a read/write memory barrier for the compiler.`
> : "=a" (res) : "m" (*lock), "a" (old), "r" (set) : "cc", "memory");
>
spin lock除非拿到锁,否则不会返回
- 传入一个参数
spin
,uintptr_t
类型,用于指示每次指数退避地等待的过程的长度。增大该参数可以在拿不到锁的情况下有效的降低CPU空转的时间,但也降低了竞争到锁的概率。不过在不繁忙时,该参数过大将导致等待锁的时间过长 - pause直接使用cpu的
pause
指令实现(以下Ref from intel manual)- Improves the performance of spin-wait loops
- When executing a “spin-wait loop,” processors will suffer a severe performance penalty when exiting the loop because it detects a possible memory order violation. (意思应该是说,频繁的读取一个volatile位置,使得需要反复的同步等,从而很昂贵(比如java 的volatile具有acquire-release语义,读取也是非常昂贵的))The PAUSE instruction provides a hint to the processor that the code sequence is a spin-wait loop. The processor uses this hint to avoid the memory order violation in most situations, which greatly improves processor performance. For this reason, it is recommended that a PAUSE instruction be placed in all spin-wait loops.
- An additional function of the PAUSE instruction is to reduce the power consumed by a processor while executing a spin loop
- In earlier IA-32 processors, the PAUSE instruction operates like a NOP instruction. The Pentium 4 and Intel Xeon processors implement the PAUSE instruction as a delay. The delay is finite and can be zero for some processors. This instruction does not change the architectural state of the processor (that is, it performs essentially a delaying no-op operation). This instruction’s operation is the same in non-64-bit modes and 64-bit mode.
cmpxchgq
- 从intel的手册来看,这个指令并没有memory barrier。这里存疑,从nginx线程池上下文来看,这个spin lock是需要memory barrier,因为
ngx_thread_pool_handler
看其来应该是在另一个线程运行的(否则也就不需要lock了),所以肯定是需要memory barrier的。然而,unlock的操作实在是非常简单#define ngx_unlock(lock) *(lock) = 0
,并没有构成lock和unlock的闭合——memory barrier似乎都是需要成对出现的 This instruction can be used with a LOCK prefix to allow the instruction to be executed atomically
To simplify the interface to the processor’s bus, the destination operand receives a write cycle without regard to the result of the comparison. The destination operand is written back if the comparison fails; otherwise, the source operand is written into the destination. (The processor never produces a locked read without also producing a locked write.)
- 从intel的手册来看,这个指令并没有memory barrier。这里存疑,从nginx线程池上下文来看,这个spin lock是需要memory barrier,因为
ngx_sched_yield
代码
1
2
3
4
5按照
sched_yield
的 manualsched_yield()
is intended for use with read-time scheduling policies (i.e.,SCHED_FIFO
orSCHED_RR
). Use ofsched_yield()
with nondeterministic scheduling policies such asSCHED_OTHER
is unspecified and very likely means your application design is broken.- 那么这里nginx应该是实时进程?(TODO)
内存池
ngx_memalign
代码
1
2
3
4
5
6
7
8
9
10
11
12/*
* Linux has memalign() or posix_memalign()
* Solaris has memalign()
* FreeBSD 7.0 has posix_memalign(), besides, early version's malloc()
* aligns allocations bigger than page size at the page boundary
*/
void *ngx_memalign(size_t alignment, size_t size, ngx_log_t *log);如果有
posix_memalign
或memalign
那么是直接使用这两个函数实现,否则是使用malloc
实现,malloc的man中有这么一句The malloc() and calloc() functions return a pointer to the allocated memory, which is suitably aligned for any built-in type
我使用x86_64
的linux 5.0.0-25-generic
做实验,每次返回的指针最后4bit都是0,也就是16B对齐在一些UNIX实现中, 无法通过调用free()来释放由memalign()分配的内存,因为此类memalign()在实现时使用malloc()来分配内存块,然后返回一个指针,指向该内存块内已对齐的适当地址(也就是指针不是指向这个块的边界,而是指向块内的某处)
其他
以下引用自OceanBase内存管理原理解析
全局内存池的意义如下:
- 全局内存池可以统计每个模块的内存使用情况,如果出现内存泄露,可以很快定位到发生问题的模块。
- 全局内存池可用于辅助调试。例如,可以将全局内存池中申请到的内存块按字节填充为某个非法的值(比如0xFE),当出现内存越界等问题时,服务器程序会很快在出现问题的位置Core Dump,而不是带着错误运行一段时间后才Core Dump,从而方便问题定位。
- 总而言之,OceanBase的内存管理没有采用高深的技术,也没有做到通用或者最优,但是很好地满足了系统初期的两个最主要的需求:可控性以及没有内存碎片。