大并发下的高性能编程 – 改进的(用户态)自旋锁

简介: 前言多线程程序中,锁的使用往往成为系统性能的关键。在做地址可视化项目的时候,由于内存管理部分需要频繁的更新内存的引用计数,所以产生了使用自旋锁的想法,这篇文章我们从自旋锁的性能开始说起,由浅入深的给出了一种改进的自旋锁的实现。

前言

多线程程序中,锁的使用往往成为系统性能的关键。在做地址可视化项目的时候,由于内存管理部分需要频繁的更新内存的引用计数,所以产生了使用自旋锁的想法,这篇文章我们从自旋锁的性能开始说起,由浅入深的给出了一种改进的自旋锁的实现。

这里我们 1) 讨论自旋锁对并发程序性能的影响; 2) glibc中自旋锁的缺陷; 3) 随后提出了一种改进的(用户空间)自旋锁的实现,供大家在今后的程序设计中参考、使用。欢迎给出改进的自旋锁中的不足和意见。

关于锁

总体上来看,锁分为两种:休眠式锁和自旋锁。休眠式锁的原理是当当前线程不能获取到指定的锁时,它就让出CPU,加入到一个等待队列中,直到被唤醒,它才会被重新调度执行。自旋锁的原理是若当前线程不能获取到指定的锁,它不会主动让出CPU,而是会在一个紧凑循环中重复的检测锁是否已经可用,即忙等待(busy wait)。

休眠锁与自旋锁的对比

如果对临界资源的访问时间很短(如更新引用计数、修改状态等),等待锁的线程只要稍等片刻即可取得资源的访问权,这个开销要远远小于进入休眠再被唤醒。为了体现自旋锁对性能的影响,我们在相同的场景下分别使用休眠锁(pthread_mutex_t) 和glibc 自旋锁(pthread_spinlock_t)来测试程序的执行时间。

测试场景如下:

  • 硬件: 4 core CPU
  • 程序功能: 多线程, 各个线程都去访问同一个数据结构(临界资源),更新此结构的引用计数,再附件一些其他操作,来模拟实际操作以耗费cpu circle, 每个线程循环执行上面的操作1千万次。
  • 线程个数 4 (= cpu核数), 8 (> cpu核数)

在不同的并发度下,mutex和glibc spinlock的执行时间如下图所示:

在并发度为4的时候,各个CPU核的使用情况如下所示

  1. mutex

  2. glibc spinlock

有上述测试可见,当并发度小于(等于)cpu core的数量时,glibc spinlock在执行效率上有明显的优势,但是当系统的并发度提高时,glibc spinlock的执行效率急剧的降低,这个问题我们将在后面章节详细描述。

由CPU的使用情况可见,使用mutex时,CPU很大程度上运行在内核态,这是因为它频繁的休眠、被唤醒导致的。而glibc spinlock则一直运行在用户态,因为它一直在CPU上循环检测锁的状态。

抛开glibc spinlock在并发度大时性能降低问题,根据上面的测试和分析,我们讨论一下适合自旋锁的场景:

  • 线程持有锁的时间很短暂
  • 系统对(锁)资源的竞争很激烈

glibc中自旋锁的缺陷

glic中自旋锁pthread_spinlock_t的实现原理正如我们上面讨论的,等待锁的线程会在一个紧凑循环中不停的检测锁的状态从而确定它何时可以获取到锁。虽然glic的自旋锁使程序的性能有了很大的提升,但是这个锁在实现中忽略了一个重要的事实:用户空间的进程受时间片轮转的控制,持有锁的进程可能被调度出CPU! 从而造成等待锁(正在自旋)的线程在自己的时间片里空转进而浪费整个时间片。内核空间的自旋锁也是使用上述原理实现的,为什么它没有问题呢?因为内核态的自旋锁spin_lock() 在获取锁的时候首先会关闭CPU调度(preempt_disable),所以直到主动释放锁,它不会被调度出CPU.

改进的自旋锁

从上面测试中CPU的利用率也可以看出,自旋锁pthread_spinlock_t一直运行在用户态,CPU利用率为100%,并且当线程并发度很高的时候系统的性能有很大的降低。在高并发的系统中,pthread_spinlock_t其实在阻碍进程并发执行的效率,因为等待锁的进程在CPU上空浪费属于它的时间片,而其他可以执行的进程必须等待。基于以上讨论,我在这里给出了一个新的自旋锁,它的实现如下,我暂且把它称作nongreedy_spinlock:

int spinlock_internal(pthread_spinlock_t *lock)
{

int ret = 0;
__asm__ (“\n”
“1:\tlock; decl %0\n\t”
“jne 2f\n\t”
“movl $0, %1\n\t”
“jmp 4f\n\t”
“\n”
“.subsection 2\n\t”
“.align 16\n\t”
“2:\tmovl $5, %%ecx\n\t”
“3:\trep; nop\n\t”
“cmpl $0, %0\n\t”
“jg 1b\n\t”
“decl %%ecx\n\t”
“jnz 3b\n\t”
“jmp 5f\n\t”
“.previous\n\t”
“5:\tmovl $1, %1\n\t”
“4:\tnop”
: “=m” (*lock), “=r”(ret) : “m” (*lock) : “%ecx”);

return ret;

}

int nongreedy_spinlock(pthread_spinlock_t *lock)
{

int rc = 0;

rc = spinlock_internal(lock);
while (rc) {
sched_yield();
rc = spinlock_internal(lock);
}
return 0;

}

上面程序的逻辑很简单易懂,基本原理就是等待锁的线程如果在给定的时间内没有获取到锁,那么它就会主动的放弃CPU ,给其他等待CPU的线程一个运行的机会。 下面我们评测一下改造后的自旋锁的性能,测试场景同上:

程序执行效率如下图:

系统中各CPU的使用情况如下:

可见,程序大部分时间运行在用户态,少量时间运行在内核态,这是由于等待锁的线程会主动让出CPU给其他等待执行的线程使用,所以在执行过程中会有较多执行任务的调度,与测试中得到的结论相同。

由上面的测试可见,无论线程的并发度是多大,经过优化的自旋锁(我们称之为nongreedy spinlock)在性能上胜过了mutex和glibc spinlock。

由于我们的系统中数据量越来越大,系统的并发度也在提高,所以应该谨慎选择锁的类型,如果使用自旋锁的话,欢迎大家评测、使用本文中给出的这个改进的实现。

总结

在程序设计的时候,要分析问题处理的场景,千万不能生搬硬套,我个人认为glibc spinlock的问题就是把linux kernel中的自旋锁生搬到用户空间造成的,它忽略了linux内核态和用户态环境的不同。我们要从中吸取经验教训。

转自:http://blogread.cn/it/article.php?id=5011

目录
相关文章
|
自然语言处理 安全 C++
【C++ 格式化输出 】C++20 现代C++格式化:拥抱std--format简化你的代码
【C++ 格式化输出 】C++20 现代C++格式化:拥抱std--format简化你的代码
8622 4
|
网络协议 安全 网络性能优化
|
算法 程序员 C语言
C/C++原子操作与atomic CAS底层实现原理
假定有两个操作A 和B,如果从执行A 的线程来看,当另一个线程执行B 时,要么将B 全部执行完,要么完全不执行B,那么A 和B 对彼此来说是原子的。
988 1
C/C++原子操作与atomic CAS底层实现原理
CMake的拷贝文件到指定目录——CMake三板斧
CMake的拷贝文件到指定目录——CMake三板斧
1878 0
|
Unix Linux Shell
CMake深度解析:掌握add_custom_command,精通Makefile生成规则(三)
CMake深度解析:掌握add_custom_command,精通Makefile生成规则
2877 2
|
消息中间件 监控 负载均衡
ZeroMQ综合指南
ZeroMQ综合指南
2772 0
|
Linux
Linux中grep命令详解与实战应用
Linux中grep命令详解与实战应用
1881 0
|
Shell Android开发 开发者
Android系统 自定义动态修改init.custom.rc
Android系统 自定义动态修改init.custom.rc
800 0
|
设计模式 运维 Java
Spring5深入浅出篇:Spring中静态代理与动态代理
Spring框架中的代理模式分为静态代理和动态代理。在JavaEE分层开发中,Service层最为重要,包含核心业务逻辑和额外功能。静态代理通过手动创建代理类来增加原始类的额外功能,但当代理类数量多时管理不便且不易于维护。动态代理则解决了这一问题,通过Spring的AOP(面向切面编程)实现,无需手动创建代理类,只需定义切点和增强(额外功能),在运行时动态生成代理对象,提高了代码的灵活性和可维护性。动态代理主要利用了JVM的字节码技术,在运行过程中创建代理类,执行完毕后随着虚拟机的结束而销毁,不会产生持久化的代理类。
|
缓存 架构师 Linux
内存优化-如何使用tcmalloc来提升内存性能?提升的结果太不可思议
内存优化-如何使用tcmalloc来提升内存性能?提升的结果太不可思议