5

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

 3 years ago
source link: https://blogread.cn/it/article/5011?f=hot1
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

浏览:6379次  出处信息

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

这里我们 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内核态和用户态环境的不同。我们要从中吸取经验教训。

觉得文章有用?立即:

和朋友一起 共学习 共进步!

建议继续学习:

QQ技术交流群:445447336,欢迎加入!
扫一扫订阅我的微信号:IT技术博客大学习

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK