服务器多线程编程相关

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

对象管理

我这边有一个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

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*