|
| 1 | +作者:徐逸锋 |
| 2 | +一、背景 |
| 3 | + |
| 4 | +人类大脑的思维是线性思维,很难同时做两件事情,涉及到编程问题,我们基本上也是以顺序编程为主,总是处理完一件事情再处理下一件事情。然而,事实上我们的程序与之相反,通常需要同时完成好几件事情,对于服务器来说,会有并发请求同时到达服务器,此时如果我们依然按人类的顺序思维来完成工作,就会效率地下,不能发挥机器的性能。 |
| 5 | + |
| 6 | +1、我们处理并发 I/O 请求的方式,主流的有两种: |
| 7 | + |
| 8 | +1.1 使用多线程 |
| 9 | + |
| 10 | +使用操作系统的多线程做同步 I/O,I/O 阻塞时,线程被调度出去,硬件 I/O 完成了,线程又被调度进来执行,完成对请求的应答。这样的好处是简单、直观、容易理解和维护,代码依然是顺序编程,一次只完成一件事情。代价就是操作系统的线程比较重,I/O 阻塞时上下文切换在内核,切换比较慢。同时,每个操作系统的线程需要占用内核堆栈,这又消耗了系统资源。 |
| 11 | + |
| 12 | +1.2. 多路复用(multiplex io) |
| 13 | + |
| 14 | +常用的并发 I/O 编程方式使用 epoll/select 对 Socket I/O 做轮询,使用 aio 处理文件 I/O,让单个系统线程能够并发地递交多个 I/O 请求,程序一边在提交 I/O 请求、硬件同时在完成 I/O,这样就提高了单个线程的 I/O 并发,同时减少了上下文切换,整体上提高了系统的 I/O 性能。 |
| 15 | + |
| 16 | +但是这样的编程方式缺点也是显而易见的,因为一个线程要同时处理好几个问题,所以编程不再是线性的,程序必须同时监控多个状态,并在中间状态进入和离开,稍有不慎,容易出错。 |
| 17 | + |
| 18 | +2、妥协的结果 --- 协程 |
| 19 | + |
| 20 | +程序始终还是需要人类去写的,所以又必须为人类思维所容易理解和接受,同时又必须要发挥机器的并发处理能力,于是人们发明了协程。 |
| 21 | + |
| 22 | +协程的优点: |
| 23 | + |
| 24 | +2.1. 继承了操作系统的多线程工作方式,依然使用人类线性思维,一次只做一件事。 |
| 25 | + |
| 26 | +2.2. 在用户态完成上下文切换,减少上下文切换的时间。 |
| 27 | + |
| 28 | +2.3. 协程通常不是强制抢占调度的,而是自愿做上下文切换的,这能充分发挥 cpu 的吞吐能力,避免了 无谓的基于时间片的上下文切换导致的 cpu cache 失效和流水线中断。 |
| 29 | + |
| 30 | +2.4 协程的缺点: |
| 31 | + |
| 32 | +2.4.1 不利于计算型密集的程序 |
| 33 | + |
| 34 | +因为不是时间片调度的,如果协程不主动切换,就会饿死其他协程。如果协程序忙循环,也会导致同样结果,忙循环是计算密集的一种特例。 |
| 35 | + |
| 36 | +2.4.2 隐含的阻塞 |
| 37 | + |
| 38 | +例如访问内存时的 page fault 导致陷入内核而不能执行其他协程。 |
| 39 | + |
| 40 | +2.4.3. 阻塞在系统调用里 |
| 41 | + |
| 42 | +如果协程阻塞在操作系统调用里(无论是直接调用导致的还是调用其他代码,而这些代码阻塞在内核态时)会导致其他协程不能执行,影响了并发。例如协程调用 malloc,而 malloc 又阻塞在 mmap 上。 |
| 43 | + |
| 44 | +2.4.4 没有调试器支持 |
| 45 | + |
| 46 | +强大的 gdb 多线程调试也不能很好的使用了,协程对 gdb 不可见。 |
| 47 | + |
| 48 | +2.4.5 代码的重入 |
| 49 | + |
| 50 | +不小心容易导致其他代码不知道当前是执行在协程的状态下,从而不能正确处理代码重入问题。例如协程 c1 当前运行在操作系统线程 A 上,使用了 pthread\_mutex\_lock,成功后它又需要等待其他资源而切换出去,此时另外一个协程序 c2 调度进来,继续在当前操作系统 A 上运行,它也使用pthread\_mutex\_lock, 由于pthread\_mutex\_t 只适合在操作系统线程级别使用,这会导致 mutex 在本操作系统线程上的重入,结果要么死锁要么产生错误。 |
| 51 | + |
| 52 | +2.4.6. 其他问题 |
| 53 | + |
| 54 | +二、 bthread 原理 |
| 55 | + |
| 56 | +bthread 的本质是协程。bthread 组件有多个基于操作系统的 pthread 的工作线程,这些工作线程不断地获取待执行的 bthread 控制结构,并执行他们。执行的方式很简单,要运行某个 bthread 时,把它的寄存器恢复到当前 cpu(在 x86\_64 上,例如普通累加器 rax, rbx 等,以及堆栈寄存器例如 rsp,和指令地址寄存器 rip,状态寄存器 rflags 等),要切换出去的时候,保存 cpu 寄存器到 bthread 的控制结构,并选择下一个 bthread 的寄存器数据恢复出来就可以。bthread 组件因为使用了多个操作系统线程,操作系统线程能被内核均衡地调度到多个 cpu 上,而 bthread 组件又能够做 work stealing,当自己这个操作系统线程没有协程可以执行时,会从其他操作系统线程上取走协程,所以能在多个操作系统线程上均衡负载。因此,bthread 组件能发挥多 cpu 的能力。 |
| 57 | + |
| 58 | +三、当前问题 |
| 59 | + |
| 60 | +当前 ChunkServer 使用了 brpc 和 braft,我们的代码大多数执行在这两个组件的上下文里。而这两个组件的执行机制,主要是使用了协程,例如当我们注册了 brpc 的服务时,服务的 method 被调用时,实际上是在一个 bthread 上下文里执行的,brpc 有多个 bthread 的工作线程(pthread),于是能并发地在多个cpu 上执行 method 的调用。 braft 也是同样的道理,很多时候执行在 bthread 的上下文里。 |
| 61 | + |
| 62 | +上面提到协程的缺点,主要是系统调用阻塞问题,但是长久以来,这个问题一直在 chunk server 中存在。例如 ext4 存储引擎,wal 和 chunk file 的读写,都使用了系统调用,这些系统调用会阻塞在磁盘 I/O 里,导致其他协程不能被执行,你可以说我们有不少 bthread 的调度线程,情况不会严重,但是别忘记,我们有很多 braft 状态机,情况会恶化,特别是大 i/o 时,阻塞时间会很长。更糟糕的是,brpc 内部代码依赖bthread 做网络 i/o,如果 bthread 的调度线程都被阻塞并耗尽,会导致网络 i/o 卡滞。如果不能尽快将socket buffer 里的数据取走,则会导致网络带宽不能很好地利用起来。 |
| 63 | + |
| 64 | +四、解决方法 |
| 65 | + |
| 66 | +1\. 使用 bthread 的同步原语, 只在叶子节点使用 pthread 同步原语 |
| 67 | + |
| 68 | +这样可以解决 bthread 的调度线程被阻塞问题。 在软件栈的最底部才能使用 pthread 原语。 例如有函数A,B,C 他们的调用方式是 A→B→C, 只有 C 可以使用 pthread 同步原语,例如 pthread\_mutex\_lock, pthread\_cond\_wait, pthread\_rwlock\_rdlock/wrlock。其他地方请使用 bthread\_mutex, bthread\_cond,bthread\_rwlock。 |
| 69 | + |
| 70 | +2\. 文件 I/O |
| 71 | + |
| 72 | +2.1 位于 bthread 上下文的代码读写文件时,如果使用 O\_DIRECT, 则可以使用 aio。 |
| 73 | + |
| 74 | +如何利用 aio 和 poll 的结合,可以看 afd 怎么使用的。<http://www.xmailserver.org/eventfd-aio-test.c>然后使用 bthread\_fd\_wait(afd, EPOLLIN)来等待 aio 的完成。 |
| 75 | + |
| 76 | +2.2 如果不能用 aio,可以启动一个线程池,专门代理服务文件 I/O,我们的 pfsdaemon 就是这么干的。 |
| 77 | + |
| 78 | + 对于读文件,可以使用 preadv2+ RWF\_NOWAIT 尝试做内核文件缓冲区读,如果失败了,则把任务交给代理线程池从磁盘上读,因为从磁盘上读注定是要阻塞的,所以让代理去做,完成了再回来唤醒我们这个 bthread,问题不大。 |
| 79 | + |
| 80 | +对于于写文件,因为我们都是要求马上落盘的,这个系统调用铁定会阻塞,所以让代理去做就好了,完成了唤醒我们就行了。 |
| 81 | + |
| 82 | +大概的工作方式是这样的: |
| 83 | + |
| 84 | + |
| 85 | +``` |
| 86 | +// 定义操作类型,可以有很多 |
| 87 | +enum Op {OP_R, OP_W, OP_FLUSH} |
| 88 | +// 定义 io 任务 |
| 89 | +struct io_task { |
| 90 | +int fd; |
| 91 | +Op op; |
| 92 | +off_t off; |
| 93 | +void *buf; |
| 94 | +size_t size; |
| 95 | +butil::atomic *butex; |
| 96 | +ssize_t result; |
| 97 | +}; |
| 98 | + |
| 99 | +// 定义一个工作队列 |
| 100 | +static moodycamel::BlockingConcurrentQueue g_work_queue |
| 101 | + |
| 102 | +// 具体的 io 实现函数 |
| 103 | +ssize_t bthread_pread(int fd, void *buf, size_t sz, off_t off) { |
| 104 | +ssize_t res; |
| 105 | +struct iovec iov; |
| 106 | +if (bthread_self() != INVALID_BTHREAD) { |
| 107 | +// 不处于 bthread 上下文 |
| 108 | +return ::pread(fd, buf, sz, off); |
| 109 | +} |
| 110 | +// 尝试内核缓冲区读 |
| 111 | +iov.io_base = buf; |
| 112 | +io.io_len = sz; |
| 113 | +res = preadv2(fd, &iov, 1, off, RWF_NOWAIT); |
| 114 | +if (res == sz) { |
| 115 | + return res; |
| 116 | +} |
| 117 | +// 让代理 pthread 读 |
| 118 | +io_task *t = new io_task; |
| 119 | +// init task |
| 120 | +t→fd = fd; |
| 121 | +t→op = R; |
| 122 | +t→off = off; |
| 123 | +t→buf = buf; |
| 124 | +t→size = sz; |
| 125 | +t→butex = bthred:butex_create(); |
| 126 | +t→butex→store(0) |
| 127 | +;g_work_queue.enqueue(t); |
| 128 | +// 等完成,bthread 上下文切换 |
| 129 | +bthread::butex_wait(t→butex, 1, NULL); |
| 130 | +bthread_butex_destroy(t→butex); |
| 131 | +// 完成了,保存返回码res = t→result;delete t; |
| 132 | +// 删除任务 |
| 133 | +return res; |
| 134 | +} |
| 135 | + |
| 136 | +ssize_t bthread_pwrite(int fd, void *buf, size_t sz, off_t off) |
| 137 | +{ |
| 138 | +ssize_t res; |
| 139 | +struct iovec iov; |
| 140 | +if (bthread_self() != INVALID_BTHREAD) { |
| 141 | +// 不处于 bthread 上下文 |
| 142 | +return ::pwrite(fd, buf, sz, off); |
| 143 | +} |
| 144 | +// 让代理 pthread 写 |
| 145 | +io_task *t = new io_task; |
| 146 | +// init task |
| 147 | +t→fd = fd; |
| 148 | +t→op = OP_W; |
| 149 | +t→off = off; |
| 150 | +t→buf = buf; |
| 151 | +t→size = sz; |
| 152 | +t→butex = bthred:butex_create(); |
| 153 | +t→butex→store(0); |
| 154 | +g_work_queue.enqueue(t); |
| 155 | +// 等完成,bthread 上下文切换 |
| 156 | +bthread::butex_wait(t→butex, 1, NULL); |
| 157 | +bthread_butex_destroy(t→butex); |
| 158 | +// 完成了,保存返回码 |
| 159 | +res = t→result; |
| 160 | +delete t; |
| 161 | +// 删除任务 |
| 162 | +return res; |
| 163 | +} |
| 164 | + |
| 165 | +``` |
| 166 | + |
| 167 | + |
| 168 | +五、前景展望 |
| 169 | + |
| 170 | +经过以上修改,可以避免 bthread 阻塞在内核导致 bthread 的调度线程耗尽,而且我们不需要许多bthread 的调度线程。理想状态下,几个就够了,多出来的 cpu 留给 spdk 和 rdma 的忙轮询线程。系统的 IO 并发度可以提高许多,大的 I/O 也不容易让系统的变慢,特别是网络的 I/O 可以及时被处理起来,所以 braft 的心跳可以及时得到维护。 bthread 的好处将真正地发挥出来。 |
| 171 | + |
0 commit comments