My Nginx Src Reading Note

Overview

多进程

  • nginx采用多进程的方式,既可以避免因某个线程故障导致整个服务不可用的问题,也可以实现配置热加载,不停服升级版本
  • nginx中除了master需要跟worker通过管道进行通信,worker之间不需要通信,而且每个worker的功能都一样,属于常驻进程。在这种场景下多线程的优势体现不出来
  • nginx、redis每个单独的进程都可以独占资源,通常情况下每个服务器会开几十个nginx、redis进程,这样如果采用多线程的模式,各个线程能够利用的资源就会受到一些限制。诸如:ulimit -n 命令展示的每个进程最多可以打开的文件数(这个可以改的;另外nginx文件读写操作不多,这里不是问题)这样的限制
  • 多进程需要刷新TLB,多线程不用、调度代价高
  • 信号处理在多线程程序开发时,也是需要考虑的一点;建议在多线程程序中,创建单独的线程利用sigwait等函数同步处理信号,其余线程直接屏蔽信号,这样避免了注册信号处理函数这种异步处理方式导致的问题
  • 进程间通信
    • 具体代码在src/os/unix/ngx_process.cngx_spawn_process
    • socketpair(AF_UNIX, SOCK_STREAM, 0, ngx_processes[s].channel) == -1)创建Unix domain socket(因为/* Solaris 9 still has no AF_LOCAL */,所以用AF_UNIX

      AF_UNIX (also known as AF_LOCAL)

    • 都是Nonblocking,并用ioctl设置FIOASYNC,也就是信号IO
    • 还设置了FD_CLOEXEC

架构

  • 高并发就是利用nonblocking使得充分利用cpu,一旦会block就可以调度出去

thread_pool

ngx_thread_pool_init

  • 创建的是detached类型的线程
  • 有一段类似于注释掉的代码,把thread的栈大小设置为PTHREAD_STACK_MIN
  • 线程中运行的函数是ngx_thread_pool_cycle
  • 要注意的是pthread中,main函数退出,那么即使还有线程没有结束,依然是程序退出。而这里并没有去等待这些线程。另外,这里不能使用join去等待,因为这是detached的线程
  • 这是一个确定大小的线程池,并且没有线程复活的机制——似乎也并不会出现线程失败而进程还活着的情况?所以并不需要线程复活的机制

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
    31
    static 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
    4
    typedef 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函数的地址
  • 里面有这么一段

    1
    2
    3
    4
    5
    6
    7
    8
    ngx_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 appropriate ngx_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
    14
    ngx_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
    31
    void
    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
    __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除非拿到锁,否则不会返回

  • 传入一个参数spinuintptr_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.)

  • ngx_sched_yield

    • 代码

      1
      2
      3
      4
      5
      #if (NGX_HAVE_SCHED_YIELD)
      #define ngx_sched_yield() sched_yield()
      #else
      #define ngx_sched_yield() usleep(1)
      #endif
    • 按照sched_yield的 manual

      sched_yield() is intended for use with read-time scheduling policies (i.e., SCHED_FIFO or SCHED_RR). Use of sched_yield() with nondeterministic scheduling policies such as SCHED_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
    */

    #if (NGX_HAVE_POSIX_MEMALIGN || NGX_HAVE_MEMALIGN)
    void *ngx_memalign(size_t alignment, size_t size, ngx_log_t *log);
    #else
    #define ngx_memalign(alignment, size, log) ngx_alloc(size, log)
    #endif
  • 如果有posix_memalignmemalign那么是直接使用这两个函数实现,否则是使用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_64linux 5.0.0-25-generic做实验,每次返回的指针最后4bit都是0,也就是16B对齐

  • 在一些UNIX实现中, 无法通过调用free()来释放由memalign()分配的内存,因为此类memalign()在实现时使用malloc()来分配内存块,然后返回一个指针,指向该内存块内已对齐的适当地址(也就是指针不是指向这个块的边界,而是指向块内的某处)

  • 以下引用自OceanBase内存管理原理解析

    全局内存池的意义如下:

    • 全局内存池可以统计每个模块的内存使用情况,如果出现内存泄露,可以很快定位到发生问题的模块。
    • 全局内存池可用于辅助调试。例如,可以将全局内存池中申请到的内存块按字节填充为某个非法的值(比如0xFE),当出现内存越界等问题时,服务器程序会很快在出现问题的位置Core Dump,而不是带着错误运行一段时间后才Core Dump,从而方便问题定位。
    • 总而言之,OceanBase的内存管理没有采用高深的技术,也没有做到通用或者最优,但是很好地满足了系统初期的两个最主要的需求:可控性以及没有内存碎片。