原点

这应该是一个终点,但应该也是另外一个起点。

文章分类 随想

协程与网络编程–coroutine_event

之前在协程实现的基础这篇文章说过了协程的概念,协程为克服同步模型和异步模型的缺点,并使结合两者的优点成为了可能。

这里要介绍的就是coroutine_event,它基于libevent,提供了基于协程的并发模型。

coroutine_event与一般的协程库不同,它并没有自己的调度管理器,因为我只想它让普通的异步程序开发变得更容易而已。那既然它没有调度管理器,一旦某个协程挂起了,它该怎么样才能再度恢复执行呢?答案就是此协程要等到某个它等待的读写事件来临时才会再度被恢复运行。

协程的运行大概是这样的过程:

A协程发起读请求,发现这时候并没有数据可读,这时候它主动放弃CPU。libevent继续它的事件循环。这时候假设A有读事件,libevent会调用相应的回调,回调里会将A协程恢复运行。

这样,对于协程来说,里面的处理都是同步的;但是对于libevent的事件循环来说,它却是异步的。这里其实类似于我们平常写的状态机,只不过这里的状态机就是整个协程的上下文,而且我们并不用关心它的挂起和恢复。

好了,废话说完了,那就上echo_server例子吧。

在创建协程前,需要先做一些准备工作。

上面的channel_create实际上是创建了一个chan,它提供了协程间通讯的通道,它本质上就类似于一个阻塞的队列,第一个参数表示传递元素的大小,第二参数表示可以容纳多少个元素;ev_base就是libevent事件循环的基础,一般来说一个线程一个event_base的实例(即一个线程有一个事件循环);coroutine_base_t就是协程运行的基础了,一个线程里面的所有协程共同拥有一个coroutine_base_t的实例,coroutine_base_t依赖于event_base,其实就是因为协程本身的调度依赖于libevent的事件循环。

这里我们创建了两个协程,一个协程用来accept新的连接,一个协程用来处理连接请求。

上面是处理accept新连接的函数,它处在一个不断的循环当中,coroutine_accept是对accept的封装,在没有新连接时自动挂起此协程,这对使用者是透明的;当有新的连接时,会将fd写入到chan里去。这里会发现并不是用chan做参数(chan_sendl),而是用了一个chan_peer_t的实例。这是因为当chan的空间满时写入数据协程会被挂起,但上面已经说了协程的调度是依赖于libevent的事件循环,所以chan_peer_t里面是放了一对管道fd,当有其他协程从chan里读数据时就随机挑一个挂起的协程发送一个读事件给它,让libevent的事件循环将它恢复运行;这里抽出来是为了避免每次读写chan时都需要创建新的管道。

这是新连接的处理函数,它不断地从chan里读出新的fd,然后为它创建一个协程,这里可以看出每个连接的真正处理函数是request_handler。

上面的函数不断地从新的连接里面读出数据,然后将数据回写回去,coroutine_read和coroutine_write就是read、write的封装,跟coroutine_accept差不多,主要是在没有数据或者缓冲区没有空间时将协程挂起。

通过上面的例子,我们采用了coroutine per connection的模型,和传统的thread per connection一样简洁清晰,但却拥有异步运行的效率,所有的协程都是在同一个线程里面运行的。也许你会奇怪为什么不直接在accept到新的连接时直接新建新的协程来处理请求,其实这样是可以的,只是这里为了顺带演示一下协程之间的通信方式。

协程的切换并不用经过内核态,而且只需要几十条机器指令,非常快速。上面简单的echo_server在一千的并发下效率并不会比直接使用libevent慢多少,甚至很接近。但由于协程本质上是一个运行上下文,需要有栈空间来保存局部变量,目前这里的实现还需要考虑栈的大小,避免空间浪费或者局部变量所占的空间超过栈大小而导致栈溢出。

在伸缩性方面,可以通过每个线程绑定一个event_base和一个coroutine_base_t,这样就可以利用多核来提高效率。但目前在做压力测试时发现chan在跨线程使用时有一点小问题,就是可能导致glibc缓存过多内存而不返回给操作系统,但并不是内存泄露,这点还得待解决。

标签: , , ,
文章分类 Unix/Linux, 网络编程

服务器多线程编程相关

最近在写一个文件缓存服务器,涉及到多个线程同步工作和共享对象等,出现了一些问题,这里大概记一下遇到的问题和解决的方式,以后遇到了也可以重新回忆下。

对象管理

我这边有一个k-v结构,k其实是一个FID(file id),v是一个指针,指向实际的数据结构。这个k-v结构是全局共享的。

这里涉及到三个需求:

1、下载请求。每个下载请求都会先从这个k-v结构里判断是否命中,命中的话则读出指针里面相应的数据返回到客户端。

2、删除请求。如果要删除的FID在k-v结构里命中,则释放掉指针指向的内存,并将此FID从k-v结构里删除。

3、淘汰和数据迁移请求。k-v结构里的所有指针会放到一个集中的vector里,由一个后台线程根据指针的一个命中字段大小还有缓存剩余空间大小决定将哪些指针释放掉,并从k-v结构里删除。

光从1和2两个需求来看,只要删除过程是排它的即可,这样在释放一个指针时别的线程是访问不了k-v结构的。但是由于3需求的缘故,这时候后台线程在访问一个指针时,它并不知道这个指针是否已经在用户线程被删除了,如果被删除了,那就可能引起程序崩溃了。

说了这么多,其实就是多个线程共享指象的问题,你根本就不知道你目前拿到的是否一个野指针。

那应该怎么做呢?我选择的是传递给线程的是一个ID,而不是指针,线程需要的时候可以根据ID拿到指针,这时候就可以知道是否已经被释放了。

但问题还有,一个是ID如何选择;另一个是只用这种方式就不会出问题了吗?

关于ID,我刚开始选择的是直接用指针的值,但是考虑到内存本身就可能被glibc缓存,很有将一个内存释放后很快又被分配回来,这样由于重用可能引起一些问题,就选择了一个累加的计数器。

由于后台线程在做数据淘汰的时候不是完全排他的,即后台线程根据ID拿到一个指针后,用户还是有可能也根据这个ID拿到指针,并将它释放掉,这还是会有问题。因此在ID的基础上引入了引用计数,获取对应着acquire,要成对使用release操作,以保证计数的正确性。

如果考虑到封装性,其实我是不希望将指针暴露出来的,根据ID来get/set数据成员可能更合适得多,依赖于程序猿的自觉保证还是不太可靠的,但这样会更加复杂,这里就从简了。

加锁粒度

上面提到用ID来转换成指针,这样就涉及到指针的集中管理,由于指针是全局共享的,因此管理器也必须是集中且全局唯一的。

刚开始我用了一个map来管理,即k是ID,v是实际的指针。由于每次的acquire和release都需要改变引用计数甚至是释放掉对象(引用计数为0且删除标志被置1的情况下),那势必要加锁。最简单的方式就是在map的最外层加一个互斥锁,每次只有一个线程能做操作,但在高并发的情况下不太合适。

那就这样,最外层加一个读写锁,然后每一个k-v项都加一个互斥锁。这样每次的访问操作都是在外层加一个读锁,然后要改变某一个指针的数据成员时再加相应的锁;需要进行删除操作时才加写锁。这样一来的话,如果数据访问比较均匀,那基本可以保证同一时间不会有太多线程争用同一个锁,但是这样一来粒度貌似过分小了,有这个必要吗?

因此做了点改进,不是一个k-v项一个互斥锁,而是将k哈希到N个桶,一个桶一个锁,可以根据实际调整桶的大小。

再考虑到一般来说一个缓存项大小可以判断得出来,再根据缓存空间可以大概推断出最多能缓存多少项。那指针管理器是否可以不用map,而直接用固定池大小的方式,即用数组来实现呢?答案是行的。这样似乎可以少掉最外层的锁,因为数组元素的个数是不会改变的,原来最外层的锁也仅仅是用来保证操纵过程中元素个数不被改变而已。但这样就要求你的所有数据都是预分配好的,删除操作也只不过是把占用标志置零而已,并不是真的删除对象。

当然有些情况不是一定得加锁的,像cache结构里面有一个成员,每次命中时它都会累加1。在单核的情况下,我们可以保证加1操作是原子性的,但多核的情况下保证不了,但我们可以通过嵌汇编用lock指令来保证incq操作的原子性,这样强行让多个线程的累加操作串行化。

这样实际上带来的开销比互斥锁应该要小些,当然我不太清楚在实际项目中是否推崇这种内嵌汇编的方式,考虑是否比直接加锁带来多大比例的好处再做决定吧。

最后

实际上遇到的还有数据同步的问题,因为文件缓存信息是一个k-v结构,而指针的集中管理又是另外一个k-v结构,这样做删除和添加操作时都需要保证两边的数据同步性。实际上如果只是单纯的删除和添加那其实同步问题很简单,只是我这边会做数据迁移和淘汰,遇到的情况更加麻烦些,但多少跟我的设计和实现有点关系,这里就不多说了,只是以后写这种多线程的程序要多加小心,尽可能理清各个处理的时序问题。

标签: , ,
文章分类 Programming, Unix/Linux

协程实现的基础

协程可以认为是一种用户态的线程,与系统提供的线程不同点是,它需要主动让出CPU时间,而不是由系统进行调度,即控制权在程序员手上。

既然看成是用户态线程,那必然要求程序员自己进行各个协程的调度,这样就必须提供一种机制供编写协程的人将当前协程挂起,即保存协程运行场景的一些数据,调度器在其他协程挂起时再将此协程运行场景的数据恢复,以便继续运行。这里我们将协程运行场景的数据称为上下文。

在linux里,有getcontext和swapcontext等接口来获取当前的上下文数据和切换上下文。那如果没有提供相应的接口,又该如何来实现呢?

其实说到底,保存下上文数据,不外乎就是保存下当前运行的栈空间的数据,还有cpu各个寄存器相应的值。只要我们能够将其保存下来,在特定的时刻恢复回去就可以了。

有人用c提供的接口setjmp和longjmp来实现协程的切换和恢复,但这里要介绍另外一种方式,即用汇编来保存/恢复cpu寄存器的值。

用汇编的方式依赖于特定的平台,这里举例的是i386 32位的*nix平台

在开始贴代码前,要先说一个概念--栈帧

ia32程序用程序栈来支持过程调用。机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。为单个过程分配的那部分栈称为栈帧。下图描绘了linux下栈帧的通用结构。栈帧的最顶端以两个指针界定,寄存器%ebp为帧指针,而寄存器%esp为栈指针。当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。

 

栈帧结构

这里我们可以看到,在调用一个函数前,都会先将各个参数、调用者在被调用函数返回时执行的下一条指令的地址--返回地址压栈,被调用函数在开始前会将%ebp的值保存,然后将当前%esp的值赋予%ebp。弄明白帧指针和栈指针的作用,以及返回地址等如何通过%ebp来获取的话,对下面保存当前上下文的汇编代码理解比较有帮助。

ucontext结构体主要关心的为uc_mcontext和uc_stack这两个成员,其中uc_stack指向一段内存,这段内存做为协程的运行栈;而uc_context为mcontext类型,各个成员保存着CPU同名的寄存器值。

上述分别是保存上下文的C接口声明和汇编实现。根据第4行汇编代码可以看出,GET函数所需要的参数值被保存到%eax,之所以根据4(%esp)来寻址,是因为这时候栈指针指向的是保存返回地址的内存地址。接着将各个寄存器的值保存到参数值指向的mcontext结构体,结合下struct mcontext以及代码里的移位看就可以了,这里就不多说了。唯一比较难理解的可能就是%eip寄存器值的获取了。由于这时候要保存的是调用GET函数的过程的上下文,这时候%eip寄存器保存的并不是调用GET函数过程的下一条指令的值,GET函数栈帧的返回地址才是调用GET函数过程返回后应该往下执行的下一条指令,因此可以看到上面汇编代码18行是将栈指针指向内存保存的值做为%eip的值保存起来。

至于恢复上下文的SET函数,要说的就是它是如何来改变%eip寄存器的值。根据上面第17行的汇编代码,它只是将新的%eip的值压栈而已,并不是直接赋予ip寄存器。我们这里再看一下当执行到ret后会怎么样。ret可以等效于这句指令--pop %eip。当SET函数返回后即将刚刚压栈的新的%eip的值恢复到ip寄存器当中去了。

使用汇编实现的GET和SET函数,实际上就可以进行上下文的保存和恢复了。但是要实现协程这还不够,协程跟线程一样,都是提供一个函数做为入口,那我们还需要为协程构建好调用其函数入口的准备,即参数压栈,栈指针的指向,还有返回地址的保存等。

第6到第9行实现了用户指定参数的入栈,第11行将返回地址指定为0.实际上linux实现的makecontext接口会根据ucontext结构体uc_link指向的值来进行设定,可以让其返回到另外一个协程继续执行。

12、13行分别设定了ip寄存器和栈指针的值,这就指定了协程开始运行的指令地址和所使用的栈空间。

makecontext函数的调用往往会伴随着SET函数的调用,由于makecontext已经指定好用户传进来的函数入口地址和栈空间的起始地址了,而SET函数返回后就会开始执行用户指定的函数了,协程开始了。

标签: , ,
文章分类 Programming, Unix/Linux

一次教训

今天一大早回到公司,测试同学就问我发短信给他干嘛。当时觉得奇怪,我都没找过他,问啥时候发。问清才知道原来是他收到了这边一个省钱电话的充值短信,看了一下,短信的内容还是旧的,不是新更新后的。当时以为是谁在做测试,也没当一回事。

过了不久,身边好些人都收到了同样的短信,这时候开始有点预感可能出问题了。上线查了一下,才发现服务端在不断地推送短信,但却没有收到任何充值通知,而且收到的短信内容都是更新前的,但是今天早上已经更新了新的,线上环境check过也确实是了。

后来发现外部队列有五千多条未下发的信息,服务端是从外部队列读取到信息然后下发的。但观察日志在重启前一直是正常的,重启之后就突然有一堆未下发的短信了。当时还怀疑是不是外部队列有问题,但是它出问题的可能信极低。由于发送短信这块是一个离职的同事负责完成的,我在他离职后由于未出啥问题和变动一直没有去看过代码。这次不得不去看了一下,光看代码没看出啥问题,后来对比了一下日志,才发现问题的所在。当时他是用一个定时器轮询外部队列是否有信息,当有信息的时候,会先取出一条,然后将发送状态置1,等到发送完成再将发送状态清零,在此期间如果定时器的回调再次被触发时判断到发送状态被置1就会直接return掉。问题就出在这里,由于发现短信所用的接口是异步的,发送状态依赖于异步回调被触发才会被清除掉。但实际上当时他使用的发送短信的客户端是参考以前的一个实现,以前的实现有一个BUG,就是发送到的服务端如果超时时是不会触发异步回调的,等于说之前是一直发不出去短信的,但是新的短信却一直积压在外部队列里,这次更新后程序正常了,所以开始下发那些迟到的短信了。

其实这个问题测试同学就有反映过,我也修复过了,当时反馈给开发短信发送模块的同学,我也就没去跟进了。后来这个服务端上线了几个月,竟然都没有人反映没有收到短信,我也没时不时观察下日志看下程序是否正常服务,以为没人爆问题就是真的没问题了。

虽说并不是我开发的,但他离职后我既然接手过来了,实际上还是应该承担起相应的责任的,应该及时跟进他的代码,至少应该想到短信下发是属于比较敏感的东西,可能给公司带来一定的影响,进而及时加上相关的警报,这样可能就不会导致这样的问题。幸好这个省钱电话还没有大量推广,影响的用户层不大,不然后果可能就真的不是这样了。

这里记下这次教训,以后无论什么事,都要及时跟进,做好一切的准备,掩耳盗铃并不是一个好的方式。也不要想当然,现在没有问题,不代表以后不会出问题,这次就是一个很好的例子。

标签: ,
文章分类 工作