用户态线程库

考虑socket的几种机制.linux,特别是windows.阻塞.消息.事件.select

前言 

我们编写的绝大多数程序都需要涉及到多线程的并发编程,一般情况下都是采用glibc提供的线程库进行开发。 
这里从线程库的实现上来考虑对于并发情况下的编程模式。 
从线程说起 
线程是什么? 
我们的多数程序都是运行在多线程模式下,通过同时并发处理多个任务来处理不同的请求。线程的特点是共享地址空间,从而高效的共享数据。与进程相比最大特点在于对于共享空间的简单处理,虽然进程间的数据共享采用mmap方式也很方便,但是毕竟涉及到共享内存的使用,需要有一定的注意,和使用要求。 多线程的概念引入,可以使得我们在一个进程中可以同时处理多个业务逻辑,而不受影响(共享数据有同步原语支持)。在多核环境下也可以有效的利用多CPU的优势。 
本质上来说, 线程在实现上采用clone的调用方式利用在堆空间上分配的内存作为,每个线程的栈空间(我们的环境中是10M或者8M,采用ulimt -s 可以看到)都是独立,包含了当前CPU的信息。目前常用的是2.6内核(部分32位机器运行在2.4内核下),2.4内核中是采用进程来模拟线程,用ps命令可以看到有许多个进程,在ps命令看来,线程基本上是等同于进程。2.6内核中一个多线程的进程,只会显示一个进程,但用gdb 或者 pstack 查看程序依然可以看到 
pthread_t 的意义, pthread_create创建线程的时候我们会拿到一个pthread_t 的返回值,在ullog中拿他做为线程号的标记,这个值本质上是维护线程数据的指针地址。 在ullog中采用这个作为线程的标记其实是存在问题的,假设这样的情况: 一个现程在启动后运行一段时间才退出,而后再启动,这个时候这个线程的数据依然会被复用,这个时候就出现了前后这两个在我们概念中是不一样的两个线程被认为是线程号相同的情况. 虽然我们在线程启动和结束的时候都会有标记特殊的日志 
我们的glibc pthread的实现中,默认栈空间是10M(一些机器上是8M),这些可以在线程启动的时候进行修改。栈空间本质上也是通过mmap的方式从堆上分配出来提供给没个线程独立使用。在具体的实现上,栈与栈之间一般都会存在一部分空间是不可写同时也不可读的,这也是为什么我们一旦栈溢出就很容易出core的原因。 栈空间的大小可以在线程启动的时候修改或者通过ulimit方式进行强制的改变。 

注:如果不考虑进程的可写数据(只读都是一样)的共享,多进程方式对比多线程其实优势更大,而且还有良好的安全性。
线程调度 
在线程库中对于线程的分配一般是受到内存大小的控制, 而CPU的数量往往不多, 这里CPU在运行的时候就涉及到,如何去运作这些线程。每个线程只有获得CPU的使用权才能执行指令.所谓的多线程的并发运行,从 宏观上看.其实就是各个线程轮流或者CPU的使用权,分别执行各自的任务.在可运行池中,会有多个处于就绪状态的线程在等待CPU。 
在我们使用的线程库中,调度往往需要考虑下面的方式 
时间片,每个线程CPU运行一段时间后,主动让出CPU 
阻塞或休眠, 比如sleep, 等待锁,系统调用等操作本身会让CPU出现一定程度上的上下文切换 
线程结束 
主动放弃CPU, 在我们的环境中是sched_yield, 可以主动让出CPU。 
在我们现在64位机器上使用的都是基于nptl线程库,相比32位中2.4内核下的linuxthread本身有很的大的提高。基本的切换上相差了近1倍。 
虽然操作系统中已经考虑了不少调度的问题,但是这些是实际中的表现往往不尽人意 
时间片不可控,一些CPU消耗性程序其实可以多运行一部分时间 
上下文切换有一定的内核态用户态的切换。 
大量的锁应用, 对性能会带来一定的影响 
当存在大量长时间堵塞的时候, 需要大量的线程支持, 会对系统产生负面影响 
这样的基础上,我们考虑采用一些其他方式来解决这些问题, 比如异步编程。


异步 
先来看看一个基本的异步程序是如何实现, 这里以aio为例 

C/C++ code
   
   
struct aiocb * aiocbp = new aiocb; memset(aiocbp, 0 , sizeof (aiocb)); aiocbp -> ;aio_fildes = fd; aiocbp -> ;aio_buf = write_buffer; aiocbp -> ;aio_nbytes = write_buffer_size; aiocbp -> ;aio_sigevent.sigev_notify = SIGEV_THREAD; aiocbp -> ;aio_sigevent.notify_function = aio_callback; aiocbp -> ;aio_sigevent.notify_attributes = NULL; aiocbp -> ;aio_sigevent.sigev_value.sival_ptr = aiocbp10 aio_write(aiocbp); ....

这 里aio调用发起后,程序处理io, io结束之后会调用aio_callback进行返回 
aiocbp 本身以及一些变量都需要自己去记录和填写, 而且不能分配在栈上, 必须要注意相应的内存管理, 在callback中需要记的把aiocbp释放掉, 这样就需要注意在aio_write之后不能使用aiocbp 
这里只是一个buffer, 如果我们复杂一些,处理一个nshead请求, 那么这里的逻辑就变的很复杂, 需要在callback再度发起一次aio处理 
若程序的整个流程都按照异步的情况来处理那么可以看到,我们的业务逻辑是一个支离破碎的过程, 特别是对于一些小的流程处理,我们也必须要做这样的细致拆分 
其实这里还有一个问题就是默认系统中的aio是伪异步IO, 只是后台多线程模拟的异步IO处理, 这种处理对于磁盘IO可能问题还不大, 但是对于网络IO在调度和线程切换上不仅不会优于同步情况,而且还带来更大的性能开销。 
一般来说异步的基本模型如下


有多个线程池,每个池处理不同的任务, 每个任务间采用事件驱动的方式来进行控制。当发现需要处理单独的任务的时候才激活,放到对应的线程池中去运行 
IO, CPU等操作都独立出来, 进行IO的时候,会主动切换到另外处理CPU的任务上, 等那边IO处理完毕会激活另外的事件回调进行处理。整个成为一个流水线式的作业,每个线程等待堵塞的地方都很少,一定程度上减少了上下文开销,可以进一步提高CPU的利用效率. 在ub 事件模型中采用的就是这种方式 
这样带来的一些好处 
对于每个任务而言, 一般线程数都很少,锁的影响很小 
处理IO不会堵塞,任务处理不和线程绑定,可以同时处理大量的请求 
但带来的最大问题就编程的复杂性。 
虽然一定程序度上这种异步的事件驱动模式更复合基本的状态机模型, 但是对于一些很简单的应用都要拆分成这么多不同阶段的任务,对于人的思维也是很不直观的。 
另外一个方面就是由于存在队列的情况, 处理任务会出现 快的很快, 慢的很慢, 这对于实时任务来说是不接受 
举个例子: 
任务1在 一个有4个线程对应4个CPU的线程池中运行, 来了5个任务, 那么第5个操作必须在前4个操作完成之后再进行. 这样在外部请求看来第5个任务花的时间是2倍的时间. 对于重吞吐的程序来说, 由于少了一些开销可以提高性能, 但是对于实时要求的程序来说,这个时候可能更希望前4个任务每个多花一点时间,使得第5个任务可以快一些完成,将单个请求花的时间平摊掉. 
不过上面的模型来处理我们实时要求高的业务,一般情况下也没有什么问题, 主要是我们一般请求处理的速度都是很快, CPU的时间片是百ms级,如果CPU没有占用超过100ms的时间, 其实是不会发生着这种主动切换的。我们切换多的地方主要还是各种IO,锁和系统调用。 
不同的任务分成多个线程池也是考虑避免某个特殊的任务把线程堵塞住。


更轻量的线程 
严格说来, 异步化的事件驱动方式出现的比线程模式更早, 线程的出现本身就是为了减轻复杂的状态机编程。 在一般情况下确实给我们的程序带来了便利. 当时另一方面也带来了不少麻烦 
锁的应用, 需要小心死锁等问题. 另外互斥情况下的性能开销也是不可忽视的 
共享数据的修改和读取很容易出现问题, 而且不好排查,即使有了valgrind 这样工具,很多问题依然是很容易出现 
但另一方面, 这些问题对于上面的提到异步方式也是同样存在的, 本质上还是存在线程间的互斥问题。不过对于这种模式可以采用单进程的方式的来规避其中的一些问题, 但在利用多CPU的问题上还是需要另外的考虑 
前面提到过多线程的本质是记录环境的上下文,保存CPU的状态。 在glibc中提供了makecontext, swapcontext的方式来记录这些信息, 利用这两个调用,我们可以在用户态上实现轻量级的线程库。 
makecontext 创建一个新的上下文,可以传入我们自己定义的栈空间。它记录CPU的各种信息 
swapcontext 切换上下文,其实就是改变CPU的相关寄存器 状态,替换到另外的可执行的上下文中 

一个简单例子: 
 一个简单例子: 
 

C/C++ code
   
   
void call_thread(uint32_t p1, uint32_t p2) { ucontext_t * ctx = (ucontext_t * )((uint64_t)p2 | ((uint64_t)p1) << 32 ); ucontext_t u; // 。。。运行线程 swapcontext( & u, ctx); } void thread_mgr() { // 设置上下文 ucontext_t ctx, u; // 初始化 u u.uc_stack.ss_sp = ( char * )stack_buff + pagesize; u.uc_stack.ss_size = thread_stack_size; u.uc_stack.ss_flags = 0 ; uint32_t p1, p2; // 低版本glibc实现问题不支持64bit指针 p1 = (uint32_t)(( 0x00000000FFFFFFFF ) & ((uint64_t)( & ctx)) & gt; & gt; 32 ); // 高位 p2 = (uint32_t)( 0x00000000FFFFFFFF & (uint64_t)( & ctx)); // 低位 makecontext( & u, ( void ( * )( void ))( & call_thread), 2 , p1, p2); swapcontext( & ctx, & u); // 记住当前位置,切换到u的位置上, 在 call_thread 中swapcontext切换回来 ... }

通过这样的切换方式我们可以在单线程程序中简单实现一个轻量级的线程库,由线程库负责在可用的调度实体上调度用户线程。这会使得线程上下文切换非常的快,这主要避免了系统调用。在测试中切换的性能是pthread库的切换(从pthread_create到线程运行线程 与 makecontext后的比较) 的10倍以上. 
事实上这是m * n 的线程库的基本实现方式, 不过这里存在几个问题 
单线程运行不能利用多CPU, 需要以来pthread或者clone的多线程实现 或者干脆利用多CPU 
调度必须自己考虑, 没有外部来进行线程的切换。 
一个轻线程出现死锁,或者需要长时间等待操作的时候会影响一批线程 
不过对于这些,我们可以在yield()主动让出CPU的语义上进行扩展, 当轻线程调用到yield(), 就主动进行切换,让另外的轻线程运行。 通过在这个层面上的封装,我们可以实现各种不同的调度方式,比如发起IO操作,非堵塞方式处理没有完全处理完毕,就切换,如果处理完毕就继续运行。 
记录当前的栈状态, 切换出去,然后再切回来, 整个流程类似下面: 
 
C/C++ code
   
   
class task { public : virtual int run() { // ... // 切换, 监控句柄 放入pool中 } } 1 // epoll_wait 等待句柄激活 // 激活的调度 if (fd 激活) { // 上下文件切换到 fd对应的地方运行

将所有的切换都采用网络句柄或者管道(纯CPU采用管道封装,统一化),这点上和前面的异步事件模型采用类似的方式, 都是通过事件方式进行激活 
事实上这种方式在erlang, ruby等语言中也都有相应的实现, 基本原理就如上面所述,每个运行实例都维护着自己的上下文和栈空间. 这种线程另一种叫法是协程(Coroutine), 本质上是属于一种非抢占式线程 
注:在具体线程实现方式上,也可以利用longjump来实现,性能上来说更好的, 不过这里对于各种状态需要自己记录,并且还需要考虑CPU缓存等问题相对问题比较(全部考虑完就和makecontext差不多了)。所以这里采用的是makecontext的方式。
可控制的异步调用 
在用户态的线程库中,我们可以采用同步思维方式写出等价于异步效果的程序,并且在性能提升方面占有一定的优势. 
一方面可以付出一定的内存开销(异步条件上下文完全通过内存池维护空间会小一些)开辟出更多的线程(几十W级别规模), 另一方面编程模式不会发生太大的改变 
计算异步化 
这里主要是考虑,类似 GPU或者数据压缩卡这样的计算模式, 将一部分的计算拿出去进行计算, CPU可以继续进行其他工作,从而短时间内处理更多的工作。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值