Redis是单进程单线程模式吗
下图为Redis5.0启动之后的效果。LWP为线程ID,NLWP为线程数量。可以看到,5.0的redis server共有四个线程,一个主线程48684,三个bio(background IO,后台io任务)线程,三个后台线程分别执行不同的io任务,我们重点考察删除一个key时的io线程执行。
Redis增加了异步删除命令unlink,防止删除大key时阻塞主线程。其原理为执行unlink时会将需要删除的数据挂到一个链表中,由专门的线程负责将其删除。而原来的del命令还是阻塞的。我们通过对一个有1000万条数据的集合分别执行del和unlink来观察其效果。
看一个大集合的删除
首先通过脚本生成一个有1000万个元素的集合testset,然后通过del命令删除,如下:
1 | 127.0.0.1:8888>info//首先调用info命令查看内存消耗: |
重新做上边的实验,这次试用unlink来删除。
1 |
|
尝试渐进式删除
为了解决这个问题,Redis作者Antirez首先考虑的是通过渐进式删除来解决。Redis也在很多地方用到了渐进式的策略,例如 lru eviction,key 过期以及渐进式rehash.原文如下:
1 | So this was the first thing I tried: create a new timer function, and perform the eviction there. Objects were just queued into a linked list, to be reclaimed slowly and incrementally each time the timer function was called. This requires some trick to work well. For example objects implemented with hash tables were also reclaimed incrementally using the same mechanism used inside Redis SCAN command: taking a cursor inside the dictionary and iterating it to free element after element. This way, in each timer call, we don’t have to free a whole hash table. The cursor will tell us where we left when we re-enter the timer function. |
大意就是把要删除的对象放到一个链表中,起一个定期任务,每次只删除其中一部分。
这会有什么问题呢,仍然看原文中说的一种案例:
1 | WHILE 1 |
如果删除没有增加快,上边这种案例会导致内存暴涨.(虽然不知道什么情况下会有这种案例发生)。于是作者开始设计一种自适应性的删除,即通过判断内存是增加还是减少,来动态调整删除任务执行的频率,代码示例如下:
1 | /* Compute the memory trend, biased towards thinking memory is raising |
这种方法有个缺陷,因为毕竟是在一个线程中,当回收的特别频繁时,会降低redis的qps,qps只能达到正常情况下的65%.
1 | when the lazy free cycle was very busy, operations per second were reduced to around 65% of the norm |
于是redis作者antirez开始考虑异步线程回收。
异步线程
共享对象
异步线程为何不能有共享数据
共享数据越多,多线程之间发生争用的可能性越大。所以为了性能,必须首先将共享数据消灭掉。
那么redis在什么地方会用到共享数据呢
如何共享
如下代码示例为Redis2.8.24.
先看执行sadd时底层数据是如何保存的
1 | sadd testset val1 |
底层保存如下(gdb过程如下,比较晦涩,参考下文解释):
1 | 254 set = lookupKeyWrite(c->db,c->argv[1]); |
通过下文结构体讲解,可以看下sadd testset val1,testset和val1保存在什么地方
1 | typedef struct dict { |
首先所有的key保存在一个dict.ht[0]的dictht结构体中。通过上边的结构体看到,dictht中的table是一个dictEntry二级指针。
执行sadd testset val1时,testset是其中一个dictEntry中的key,key是一个void*指针,实际存储情况为testset保存为一个char *类型
假设testset经过哈希之后index为3,则dict.ht[0].table[3].key为testset,dict.ht[0].table[3].v.val为一个void*指针,实际存储一个robj *类型
第三步中的robj中有个ptr指针,指向一个dict类型。dict中的其中一个entry的key指向另一个robj指针,该指针的ptr指向val
即获取一个值的流程为:
key -> value_obj -> hash table -> robj -> sds_string
然后看两个共享对象的典型场景:
1.sunionstore命令
看下代码实现:
1 |
|
执行以下命令:
sadd testset1 value2
sunionstore set testset1 testset2 //即将testset1和testset2的元素取并集并保存到set中
然后我们可以通过查看testset的元素,看看其引用计数是否变为了2
smembers testset
1 |
|
2.smembers命令
返回元素的时候,重点看返回时的代码
1 |
|
会直接将robj对象作为返回参数
并且客户端传入参数也是一个个robj对象,会直接作为值保存到对象中
共享时如何删除
那么,共享对象在单线程情况下是如何删除的呢?
看看del命令的实现
del调用dictDelete,最终调用每个数据类型自己的析构函数
1 | dictFreeKey(d, he); |
集合类型调用如下函数
1 | void dictRedisObjectDestructor(void *privdata, void *val) |
可以看到,只是将值的refcount减1
如何解决共享数据
新版本如何解决了共享数据
还是通过sunionstore和smembers命令看下这两处如何解决共享:
以下代码使用redis 5.0.3版本介绍:
1 | void saddCommand(client *c) { |
增加值的时候已经变为了一个sds.
现在的保存结构为:
key -> value_obj -> hash table -> sds_string
而返回到客户端的时候也变为了一个sds,如下:
1 |
|
效果如何
效果如何呢?
首先取值的时候从robj的间接引用变为了一个sds的直接引用。
其次减少了共享会增加内存的消耗,而使用了sds之后,每个sds的内存占用会比一个robj要小。我们看下antirez如何评价这个修改:
1 |
|
说了两层意思,一是内存使用更加高效了
二是更少的间接引用导致redis比以前更加快,而且客户端输出更加简洁和快速。
异步线程
异步线程的实现以后在详细描述
问题
1.多线程之间在堆上分配内存时会有争用。但是antirez说因为redis在内存分配上使用的时间极少,可以忽略这种情况。
如何考虑这个问题?
参考:https://software.intel.com/zh-cn/articles/avoiding-heap-contention-among-threads