文章目录
C4 基于锁的并发数据结构
如何对数据结构加锁,保证其高性能,让许多线程同时访问该结构,完成并发访问
4.1 并发计数器
计数器是最简单的数据结构,使用广泛且接口简答,以下是一个非并发的计数器:
typedef struct counter_t {
int value;
}counter_t;
void init(counter_t *c) {
c->value = 0;
}
void increment(counter_t *c) {
c->value++;
}
void decrement(counter_t *c) {
c->value--;
}
int get(counter_t *c) {
return c->value;
}
没有同步机制的计数器很简单,只需要很少的代码就能实现,接下来要让这段代码线程安全
(1) 简单不可扩展的计数器
typedef struct counter_t {
int value;
pthread_mutex_t lock;
}counter_t;
void init(counter_t *c) {
c->value = 0;
pthread_mutex_init(&c->lock, NULL);
}
void increment(counter_t *c) {
pthread_mutex_lock(&c->lock);
c->value++;
pthread_mutex_unlock(&c->lock);
}
void decrement(counter_t *c) {
pthread_mutex_lock(&c->lock);
c->value--;
pthread_mutex_unlock(&c->lock);
}
int get(counter_t *c) {
pthread_mutex_lock(&c->lock);
int rc = c->value;
pthread_mutex_unlock(&c->lock);
return rc;
}
这个并发计数器简单,正确,实质上只是加了一把锁,在调用函数操作该数据结构时获取锁,从调用返回时释放锁
有了这个并发数据结构后,考虑其性能,为了理解这个简单的并发数据结构的性能,运行一个基准测试,有1-4个线程,每个线程更新计数器100万次,其结果为:
从精确曲线可以看出,简单同步计数器的扩展性不好,单线程完成更新100万次计数器大约0.03s,而两个线程并发执行各自更新100万次计数器需要约5s,线程更多时,性能更差
理想情况下,在多处理器上运行的多线程就像单线程一样块,达到这种状态称为完美扩展,虽然工作量增多,但是并行执行后,完成任务的总时间并没有增加
(2) 可扩展的计数器
可扩展计数器很重要,没有可扩展的计数,一些运行在Linux上的工作在多核机器上将遇到严重的扩展性问题,有若干种计数解决它,这里介绍懒惰计数器
懒惰计数器通过多个局部计数器和一个全局计数器来实现一个逻辑计数器,其中每个CPU核心有一个局部计数器,即4核CPU上,有4个局部计数器和1个全局计数器,且每个局部计数器上都有一个锁,全局计数器有一个锁
其基本思想是:如果一个核心上的线程想增加计数器,先去增加自己的局部计数器,访问这个局部计数器是通过对应的局部锁同步的,因为每个CPU都有自己的局部计数器,不同CPU上的线程不会竞争,所以计数器的更新操作可扩展性好
但为了保持全局计数器更新,局部计数器的值会定期转移给全局计数器,通过阈值S来进行,当局部计数器的值到达S,获取全局计数器的锁,让全局计数器的值加上S,再清空局部计数器的值,S越小,懒惰计数器越趋近于非扩展的计数器,S越大,扩展性越强
以下是懒惰计数器的性能,在4核CPU上的4个线程,分别更新计数器100万次,如果S很小,则性能较差,如果S增大,则性能会变好:
4.2 并发链表
(1) 基本的并发链表
这里只关注链表的插入操作:
void List_Init(list_t *L) {
L->head = NULL;
pthread_mutex_init(&L->lock, NULL);
}
void List_Insert(list_t *L, int key) {
node_t *new = malloc(sizeof(node_t));
if(new == NULL) {
perror("malloc");
return;
}
new->key = key;
pthread_mutex_lock(&L->lock);
new->next=L->head;
L->head = new;
pthread_mutex_unlock(&L->lock);
}
int List_Lockup(list_t *L, int key) {
int rv = -1;
pthread_mutex_lock(&L->lock);
node_t *curr = L->head;
while(curr) {
if(curr->key == key) {
rv = 0;
break;
}
curr = curr>next;
}
pthread_mutex_unlock(&L->lock);
return rv;
}
(2) 扩展链表
有了最基本的并发链表,但又遇到了这个链表扩展性不好的问题,为了解决这个问题,有一种技术称为过手锁,也称锁耦合
原理是:对于链表,让每个节点都有一个锁,替代之前整个链表一个锁,遍历链表时,首先抢占下一个节点的锁,然后释放当前节点的锁
过手锁增加了链表的并发程度,但是链表每个节点获取,释放锁的开销巨大,很难比单锁的方法快,即使有大量的线程和很大的链表,这种并发方案也不一定会比单锁的方案快
更多的并发不一定更快,如果高并发方案带着大量的开销(如频繁获取锁,释放锁),那么高并发就没有什么意义
4.3 并发队列
对于各种需要并发进行的数据结构,最简单的方法就是创建一个并发数据结构,加一把“大锁”
对于一个队列,忽略这种一把锁的方案,看看并发数据结构种加多个锁:
对于并发队列,可以增加两个不同的锁,一个负责队列头,另一个负责队列尾,这两个锁使得入队列操作和出队列操作可以并发执行,因为入队列只访问tail锁,出队列只访问head锁
队列在多线程程序中广泛使用,这里的队列通常不能完全满足这种程序的需求,更完善的有界队列,在队列空或者满时,能让线程等待
4.4 并发散列表
只关注不需要调整大小的简单散列表:
#define BUCKETS(101)
typedef struct_hash_t {
list_t lists[BUCKETS];
} hash_t;
void Hash_Init(hash_t *H) {
int i;
for(i = 0; i < BUCKETS; i++) {
List_Init(&H->lists[i]);
}
}
int Hash_Insert(hash_t *H, int key) {
int bucket = key % BUCKETS;
return List_Insert(&H->Lists[bucket], key);
}
int Hash_Lookup(hash_t *H, int key) {
int bucket = key % BUCKETS;
return List_Lookup(&H->lists[bucket], key);
}
这里的散列表使用了之前实现的并发链表,性能很好,每个散列桶(每个桶都是一个链表)都有一个锁,而不是整个散列表只有一个锁,从而支持高并发操作,下图是4核CPU,4个线程,每个线程分别执行1-5万次并发更新:
避免不成熟的优化,实现并发数据结构时,先从最简单的方案开始,也就是加一把大锁来同步,这样做可能构建了正确的锁,如果发现了性能问题,那么就改进方案,只要优化到满足即可,理应避免过度的优化
4.5 小结
控制流变化时需要注意获取锁和释放锁,增加并发不一定能提高性能,有性能问题的时候再做优化,且应该避免不成熟的优化,让整个应用的某一小块变快,却没有提高整体性能是没有价值的