本文 主要分析 st_thread_create()
函数的内部实现。
st_thread_create
流程图如下:
现在开始一行一行代码分析 st_thread_create
_st_thread_t *thread;
_st_stack_t *stack;
void **ptds;
char *sp;
上面代码声明了 4 个变量 ,变量 ptds
的全称应该是 pthread_data_register(线程的寄存器缓存),sp 的全称 是 stack point。
/* Adjust stack size */
if (stk_size == 0)
stk_size = ST_DEFAULT_STACK_SIZE;
stk_size = ((stk_size + _ST_PAGE_SIZE - 1) / _ST_PAGE_SIZE) * _ST_PAGE_SIZE;
stack = _st_stack_new(stk_size);
if (!stack)
return NULL;
上面开始申请 栈内存,ST_DEFAULT_STACK_SIZE
是 (64*1024) ,也就是 64Kb 默认内存。还有一个 宏定义 _ST_PAGE_SIZE
,定义如下:
#define _ST_PAGE_SIZE (_st_this_vp.pagesize)
_st_this_vp.pagesize
是用 getpagesize()
获取操作系统的内存页大小。所以下面的这行代码,又乘,又除,实际上是为了得出 操作系统内存页大小 的倍数。
stk_size = ((stk_size + _ST_PAGE_SIZE - 1) / _ST_PAGE_SIZE) * _ST_PAGE_SIZE;
为什么 stk_size 要是 操作系统 内存页大小的 倍数 ,这个涉及到内存对齐,虚拟内存跟物理内存的映射原理,通俗来说,是为了让内存使用更加高效,推荐阅读
然后 就调 _st_stack_new()
函数 来申请内存。在 讲 _st_stack_new
函数的内部逻辑 之前,先普及一下 mmap
跟 calloc
的区别。
mmap
的一个常见的用法,是把文件映射到内存,例如把 2G 的大文件映射到内存,然后写内存就相当于写文件,但是并不会占用 2G 的物理内存。mmap 他这个映射是虚拟的,如果不使用文件映射,可以把 fd 设置为 -1 就是 匿名映射。而 calloc 函数申请的是实实在在的物理内存,物理内存的使用量会增加。
mmap 例子:
#include <unistd.h>
#include <sys/mman.h>
int main() {
void* vaddr = mmap(NULL, 1024 * 1024 * 1024 * 1, PROT_READ | PROT_WRITE, 34, -1, 0);
sleep(600);
return 0;
}
calloc 例子:
#include <unistd.h>
#include <stdlib.h>
int main() {
void* vaddr = calloc(1, 1024 * 1024 * 1024 * 1);
sleep(600);
return 0;
}
执行命令编译:
gcc -o calloc calloc.c
然后用 top -p 100054
查看指定进程的情况,如下图:
回到 _st_stack_new()
函数的分析,流程图如下:
从流程图可以看出,_st_stack_new
函数主要有两个重点:
1,用 calloc 来申请一块内存,_st_stack_t
这个结构体是 56 字节,因为 calloc 会初始化为 0,会导致 commit。所以物理内存的使用是实实在在增加了 56 字节。代码如下:
if ((ts = (_st_stack_t *)calloc(1, sizeof(_st_stack_t))) == NULL)
return NULL;
2,用 _st_new_stk_segment
来申请栈的实际内存,_st_new_stk_segment
函数比较简单,就是对 mmap
封装一下。
// REDZONE 等于一个内存页大小
ts->vaddr_size = stack_size + 2*REDZONE + extra;
ts->vaddr = _st_new_stk_segment(ts->vaddr_size);
st_thread_create()
函数传给 _st_stack_new()
函数的 stack_size
参数是 65536 ,也就是 64 字节,但是不会立马使用 64 kb 的物理内存,因为用的是 mmap。不是 calloc。注意,实际上 他还加了 2*REDZONE,多了两个内存页大小。为什么要冗余两个内存页大小,请看文章《ST源码分析-内存保护》。
_st_free_stack
的逻辑本文不会讲解太多,因为这是入门教程,第一次调 _st_stack_new()
是不会跑进去 _st_free_stack
条件的,不会跑进去的逻辑本文不会讲解太多,力求简单。
_st_stack_new 运行后之后,整个栈内存的结构是这样的...
调用_st_stack_new()
拿到这么一个 栈结构之后,回到 st_thread_create()
的逻辑,代码如下:
#if defined (MD_STACK_GROWS_DOWN)
sp = stack->stk_top;
#ifdef
因为我的环境 栈是向下压的,所以 MD_STACK_GROWS_DOWN 定义了。
sp = sp - (ST_KEYS_MAX * sizeof(void *));
ptds = (void **) sp;
上面的代码,ST_KEYS_MAX 等于 16,(void *) 在 32位占 4字节,在 64 位占 8字节。所以上面的代码逻辑是 从之前申请的内存里面拿出 16 * 8 个字节的内存 存储协程的私有数据,这块内存在 ST 代码里没有使用,是留给使用者自由发挥的。想存什么就存什么变量 ptds
的全称是 private_data
,ptds
指向数据的开始地址。
sp = sp - sizeof(_st_thread_t);
thread = (_st_thread_t *) sp;
再 从 之前申请的内存里面拿出 sizeof(_st_thread_t)
大小的内存来存储 _st_thread_t
协程。
/* Make stack 64-byte aligned */
if ((unsigned long)sp & 0x3f)
sp = sp - ((unsigned long)sp & 0x3f);
stack->sp = sp - _ST_STACK_PAD_SIZE;
上面的代码就是把 sp
做 64位对齐,最后 再拿走 _ST_STACK_PAD_SIZE
大小的内存,_ST_STACK_PAD_SIZE
等于 128 。
上面全部代码执行完之后,栈的内存布局如下:
从上图可以看出,stack->stk_bottom
~ stack->stk_sp
中间的空白区域,这块区域 是 协程函数局部变量用的。请看文章《ST源码分析-协程局部变量》
继续 分析 st_thread_create()
的后续代码逻辑。
memset(thread, 0, sizeof(_st_thread_t));
memset(ptds, 0, ST_KEYS_MAX * sizeof(void *));
/* Initialize thread */
thread->private_data = ptds;
thread->stack = stack;
thread->start = start;
thread->arg = arg;
上面这些都是 变量操作,初始化逻辑,跳过,继续看下面代码。
_ST_INIT_CONTEXT(thread, stack->sp, _st_thread_main);
_ST_INIT_CONTEXT
实际上就是 MD_INIT_CONTEXT
,我的环境是 Linux
+ x86_64
,所以MD_INIT_CONTEXT
宏定义如下:
#define MD_INIT_CONTEXT(_thread, _sp, _main) \
ST_BEGIN_MACRO \
if (MD_SETJMP((_thread)->context)) \
_main(); \
MD_GET_SP(_thread) = (long) (_sp); \
ST_END_MACRO
MD_SETJMP
宏定义如下:
#define MD_SETJMP(env) _st_md_cxt_save(env)
_st_md_cxt_save
函数的汇编实现如下:
.globl _st_md_cxt_save
.type _st_md_cxt_save, @function
.align 16
_st_md_cxt_save:
/*
* Save registers.
*/
movq %rbx, (JB_RBX*8)(%rdi)
movq %rbp, (JB_RBP*8)(%rdi)
movq %r12, (JB_R12*8)(%rdi)
movq %r13, (JB_R13*8)(%rdi)
movq %r14, (JB_R14*8)(%rdi)
movq %r15, (JB_R15*8)(%rdi)
/* Save SP */
leaq 8(%rsp), %rdx
movq %rdx, (JB_RSP*8)(%rdi)
/* Save PC we are returning to */
movq (%rsp), %rax
movq %rax, (JB_PC*8)(%rdi)
xorq %rax, %rax
ret
.size _st_md_cxt_save, .-_st_md_cxt_save
回到 MD_SETJMP((_thread)->context)
这里的 context 是一个 jmp_buf
的结构, jmp_buf
是操作系统提供的,推荐阅读《ST源码分析-setjmp》。
为了理清楚 MD_SETJMP((_thread)->context)
这句代码干了什么事情,需要一步一步分析。
MD_SETJMP((_thread)->context)
等于 _st_md_cxt_save(env)
,_st_md_cxt_save
是一个汇编函数,之前没有讲过 汇编函数,如果传参,这个参数是怎么压进去堆栈,寄存器又是如何变换,现在就用 gdb 演示一次。命令如下:
gdb ./obj/lookupdns
# 设置参数
set args xianwaizhiyin.net www.baidu.com
# 断点 1
b *st_thread_create
# 断点 2
b sched.c:588
# 查看 寄存器
layout regs
# 查看 C 源码
layout src
# 查看 汇编 源码
layout asm
# 打印地址
print &(thread->context)
如上图所示,在调 callq
之前 ,rax
跟 rdi
的值 就是 thread->context
的内存地址。所以参数 env
是通过 rdi
寄存器传进去给 _st_md_cxt_save
函数用的。
下面继续 分析 _st_md_cxt_save
函数的代码:
_st_md_cxt_save:
/*
* Save registers.
*/
movq %rbx, (JB_RBX*8)(%rdi)
movq %rbp, (JB_RBP*8)(%rdi)
movq %r12, (JB_R12*8)(%rdi)
movq %r13, (JB_R13*8)(%rdi)
movq %r14, (JB_R14*8)(%rdi)
movq %r15, (JB_R15*8)(%rdi)
上面的代码从逻辑是看,就是保存 6 个寄存器 的值到 pthread->context
(jmp_buf)里面。
他这个是直接搞清楚了 jmp_buf 的内存布局,然后按字段来填数据。没有直接用 C语言的函数 setjmp()
跟 longjmp()
来操作 jmp_buf
,而是自己写汇编来操作 jmp_buf
。
这里展示一下 jmp_buf
的定义:
/* Calling environment, plus possibly a saved signal mask. */
struct __jmp_buf_tag
{
/* NOTE: The machine-dependent definitions of `__sigsetjmp'
assume that a `jmp_buf' begins with a `__jmp_buf' and that
`__mask_was_saved' follows it. Do not move these members
or add others before it. */
__jmp_buf __jmpbuf; /* Calling environment. */
int __mask_was_saved; /* Saved the signal mask? */
__sigset_t __saved_mask; /* Saved signal mask. */
};
typedef struct __jmp_buf_tag jmp_buf[1];
上面的 jmp_buf[1] 实际上就是 定义一个数据,jmp_buf
就等于 __jmp_buf_tag[1]
。struct __jmp_buf_tag = {0}
跟 struct __jmp_buf_tag[1] = {0}
是不一样的,后面的是 __jmp_buf_tag*
是指针。
__jmp_buf
的定义如下,是一个 8 元素的数组:
typedef long int __jmp_buf[8];
接着看下面代码:
/* Save SP */
leaq 8(%rsp), %rdx
movq %rdx, (JB_RSP*8)(%rdi)
/* Save PC we are returning to */
movq (%rsp), %rax
movq %rax, (JB_PC*8)(%rdi)
这里用的是 lea
指令,lea
会把 8(%rsp)
这个内存地址 赋值给
rdx
寄存器 ,而不是把 内存地址的数据赋值给 rdx
寄存器,这个跟 mov
有差异 , 推荐阅读《汇编语言中mov和lea的区别有哪些?》
上面的 Save SP
就是把 rsp+8
然后保存进去 jmf_buf
里面,为什么要加 8 ?rsp + 8
就是没 call _st_md_cxt_save
函数之前的 rsp
的值,因为 call 会把 rsp 减 8 字节。这里 保存 rsp+8
的值,应该是多余的,因为会被后面的 MD_GET_SP(thread) = (long) (stack->sp);
覆盖掉。
然后再把 rsp
的值 保存到 jmf_buf
。PC 是 Program Counter 的缩写,用于指示当前将要执行的下一条机器指令的内存地址 ,也就是 EIP 寄存器。
此时此刻 rsp
~ rsp +8
这 8 字节内存,存的是什么呢?存的是 调 call 的上层函数的下一条指令的位置,在 C代码里面就是 下面的 if 判断 返回值,
if (MD_SETJMP((_thread)->context))
。
因为 执行完 call 之后要跳回调上层调用下一行指令的位置,这个位置 就存在 rsp
~ rsp +8
这 8 字节内存 里面。如下图:
上图,是刚刚用 si
跳进去 call
里面,call
指令已经执行了,但是里面的指令还没执行,此时此刻 0x7fffffffde88 ~ 0x7fffffffde90 存的就是 st_thread_create
函数的下一条指令。
保存好这些东西 进去 jmp_buf
之后,就会操作 rax
搞返回值 ,如下:
xorq %rax, %rax
上面这句代码是异或操作,只是把 rax
搞成 0 ,并没有什么特别。
_st_md_cxt_save
执行完了,回调之前的 宏函数 。由于 MD_SETJMP 把 rax
搞成 0 ,所以下面的 _main()
并不会执行。那 _main()
什么时候执行呢?这里先简单剧透一下,在 _st_md_cxt_restore()
函数里面会把 rax
设置 为 1 ,再跳回到这个 if
判断。因为 MD_SETJMP
已经把 if
代码的指令地址 存进去 jmp_buf里面了,所以可以跳回来。
#define MD_INIT_CONTEXT(_thread, _sp, _main) \
ST_BEGIN_MACRO \
if (MD_SETJMP((_thread)->context)) \
_main(); \
MD_GET_SP(_thread) = (long) (_sp); \
ST_END_MACRO
所以上面的代码, if
执行之后,只会执行 下面这句代码。
MD_GET_SP(_thread) = (long) (_sp);
MD_GET_SP
宏定义如下:
#define MD_GET_SP(_t) (_t)->context[0].__jmpbuf[JB_RSP]
由于 参数 _sp
是 stack->sp
,所以是把 stack->sp
存储在__jmpbuf[JB_RSP]
的位置,但之前在 _st_md_cxt_save
里面,__jmpbuf[JB_RSP]
已经被用了,如下:
/* Save SP */
leaq 8(%rsp), %rdx
movq %rdx, (JB_RSP*8)(%rdi)
这里又会被覆盖,为什么要覆盖,这里估计是作者写多了。我把他这句 movq %rdx, (JB_RSP*8)(%rdi)
注释掉也没问题,补充:这里可能是其他平台会用到这句 movq
。覆盖之后的情况如下图:
MD_GET_SP
是重量级函数,这个函数可以把 刚刚申请的内存的那段还未使用的空白内存,作为协程函数的栈内存。 什么是协程函数的栈内存,首先,协程函数里面如果使用局部变量,是不是要压栈?就会把 RSP
减少,把局部变量写进去 RSP 寄存器指向的内存。
所以上面 把 (_t)->context[0].__jmpbuf[JB_RSP]
修改为 stack->sp
就是为了分配 协程函数的 栈内存。因为在 _st_md_cxt_restore()
的时候,会把 这个 __jmpbuf[JB_RSP]
恢复到 RSP 寄存器。如下图:
至此 _ST_INIT_CONTEXT()
函数分析完毕。
现在 st_thread_create()
函数还有以下代码没有执行:
/* Make thread runnable */
thread->state = _ST_ST_RUNNABLE;
_st_active_count++;
_ST_ADD_RUNQ(thread);
return thread;
上面的代码只有 _ST_ADD_RUNQ
是重点,仔细分析一下这个函数,定义如下:
#define _ST_ADD_RUNQ(_thr) ST_APPEND_LINK(&(_thr)->links, &_ST_RUNQ)
/* Append an element "_e" to the end of the list "_l" */
#define ST_APPEND_LINK(_e,_l) ST_INSERT_BEFORE(_e,_l)
/* Insert element "_e" into the list, before "_l" */
#define ST_INSERT_BEFORE(_e,_l) \
ST_BEGIN_MACRO \
(_e)->next = (_l); \
(_e)->prev = (_l)->prev; \
(_l)->prev->next = (_e); \
(_l)->prev = (_e); \
ST_END_MACRO
上面的代码有点绕,但实际上就是把 协程 的 links
插进去 全局管理器 _st_this_vp.run_q
,他们都是 _st_clist
结构。 _st_clist
结构 在很多地方都有用到。
这个 _st_clist 的设计比较精巧,定义如下:
typedef struct _st_clist {
struct _st_clist *next;
struct _st_clist *prev;
} _st_clist_t;
没错,只有一前一后 两个字段。实际上 next
存的是 某个协程的 links
字段的内存地址。因为 _st_thread 内部的字段的内存地址都是连续的,所以根据 links
字段的内存地址 做 offset 就能定位到 _st_thread 的头部的地址。有一个封装好的宏函数 _ST_THREAD_PTR()
,根据 links 的内存地址找到 协程。如下:
#define _ST_THREAD_PTR(_qp) \
((_st_thread_t *)((char *)(_qp) - offsetof(_st_thread_t, links)))
重新看 回去 st_init() ,st_thread_create 函数实际上就是创建了一个结构体变量 _st_thread_t pthread
,这个结构体里面保存了,pthread->start 函数未执行之前的寄存器信息。
st_thread_create 函数分析完毕。
总结:
1,申请内存,拿出 336 字节 初始为 _st_thread_t
结构,后续这块内存的管理通过 _st_thread_t
结构进行管理,例如 :thread->private_data
,thread->stack
。
2,然后用汇编 把寄存器信息保存进去 thread->context
(jmp_buf) 。
其实分析到这里,大家会发现,st_thread_create() 虽然创建了协程,但是并没有开始运行协程。从 lookupdns
程序看来。创建了两个协程,如下:
1,st_thread_create(_st_idle_thread_start,...)
2,st_thread_create(do_resolve,...)
但是这两个函数 _st_idle_thread_start()
,跟 do_resolve()
并没有开始运行。那是什么时候开始运行 呢?在 st_thread_exit()
里面。
请看 下一篇文章《ST源码分析-st_thread_exit》。
扩展知识:
1,GCC 编译的时候加上-gdwarf-2
和 -g3
编译选项可以打印宏。
相关阅读:
由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 Loken1。QQ:2338195090。