ST源码分析-st_thread_create - 弦外之音

/ 0评 / 0

本文 主要分析 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 函数的内部逻辑 之前,先普及一下 mmapcalloc 的区别。

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_dataptds 指向数据的开始地址。

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 之前 ,raxrdi 的值 就是 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]

由于 参数 _spstack->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_datathread->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 编译选项可以打印宏。


相关阅读:

  1. 《杨成立state-threads代码分析》
  2. 《汇编语言中mov和lea的区别有哪些?》
  3. 《Linux中mprotect()函数详解》

由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 Loken1。QQ:2338195090。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注