掘金 后端 ( ) • 2024-04-30 10:10

在最近的一些项目开发过程中,接触到了一些多线程编程的场景。多线程编程中涉及的同步、通信相关的机制较为繁琐,日常开发中容易造成困惑。本文旨在介绍一下多线程编程的一些基本概念,以及我在开发过程中的浅薄经验,对于具体的实现还请参考其他资料。

并行和并发

并行是指在同一时刻,多个线程或进程同时执行不同的任务,利用多核处理器或分布式系统的能力,同时处理多个任务,以提高系统的处理能力。

并发则是指在同一时间段内,多个线程或进程按照一定的调度交替执行,实现看起来同时执行的效果(但其实是同一个CPU的串行处理)。这里的并发发生于系统自身的调度、运行线程主动让出CPU、内核抢占、中断、软中断等多种情形下。

进程和线程

进程拥有自己独立的地址空间,我们往往把软件流程放在同一个进程还是不同进程作为划分功能的重要决策点。进程之间不存在临界区的访问冲突,但是信息的传递需要通信。

线程之间分享同一地址空间,可以高效地访问同一个资源(当然进程之间也可以通过映射同一块物理内存来实现共享内存,但这种方法我们还是把它当作进程间的通信而不是资源的共享)。但因此导致的临界区竞态需要在编程时加以同步和保护。

这里就引出多进程和多线程两个重要的议题:通信和同步。

什么时候需要多进程

将一个系统设计成多进程,主要是考虑进程天然隔离的特点,实现设计上的高内聚低耦合。一方面避免任务之间的干扰;另一方面提升系统健壮性,即使其中一个进程异常,也不影响其他进程继续工作。

什么时候需要多线程

思考一下,对于一个跑在单核CPU上的任务(假定是整数自增到10000停止),让它单线程的执行和分成10个线程执行,哪个方法更快运行完?

下意识的,会认为10个线程干活肯定比1个线程快,但这是一个并发任务,同一时间并不会有多个线程同时运行,反而cpu会把更多的时间浪费在线程上下文的切换中,单核CPU的多线程对于性能是无意义的。

另一方面,多线程不是实现特定功能的必须。一段多线程的程序,也完全可以通过单线程状态机跳转的思路来完成。当然多线程可以作为一种编程模型来理解,便于编码实现。

总的来说,多线程的价值是为了更好的发挥现代多核cpu的效能。

进程的通信

Linux进程的通信(IPC)方式有很多种,例如匿名管道pipe、命名管道fifo、posix消息队列、socket、共享内存、信号等。大部分场景下,推荐使用socket。特殊场景下,可以根据具体需求使用其他IPC。

Socket

socket不仅可以用于网络通信,也可以用于进程通信,并且因此可以跨越操作系统边界实现不同设备、不同平台间的进程通信。同时socket支持丰富的协议族,保证通信可靠可以选择基于字节流的tcp协议族,对性能有要求的进程通信可以选择udp协议族。

相比之下,其他ipc都不能跨主机通信,且适用场景并不如socket广泛(例如pipe要求亲缘关系的进程且为半双工,信号要求函数必须可重入)。因此,对于传递消息类型的ipc需求,尤其是考虑到分布式部署和平台兼容,socket无疑是最好的选择。

共享内存

共享内存直接将物理内存映射到进程空间,进程无需数据的拷贝和上下文切换,可以高效地读写共享内存区域。并且尤其适用于大量数据的频繁交换,对于复杂数据结构(共享缓冲区、内存映射文件)的通信也较容易实现。

使用共享内存限制包括:需要额外的同步手段来确保数据一致性和安全性(一般是文件锁);不适用于跨主机的ipc;增加了进程间的耦合,一个进程出问题可能会波及其他进程。

多线程的同步

Linux多线程的同步方法同样很多。我把它们区分为两类:一类是保护共享资源,例如锁、信号量、条件变量、原子操作等。

自旋锁spinlock

自旋锁是非可重入的,线程在尝试获取自旋锁失败后会进入自旋,轮询获取锁。

互斥锁mutex

互斥锁可设置重入或非可重入,但是一般使用时建议设置为非可重入,避免产生歧义。互斥锁获取失败会导致线程阻塞,直到锁释放,线程再继续执行。

读写锁作为互斥锁的一种,被设计在读多写少的场景下使用以提高性能,区分了读和写的行为,读锁共享,写锁冲突。但在实际使用读写锁时,容易犯在持有读锁的时候修改共享数据错误,且持有写锁时还是会导致读锁阻塞影响性能,所以读多写少场景还是更推荐下文rcu的方法。

条件变量

锁可以认为是排他性的访问共享数据,我们期望在并发场景调用的时候不要阻塞,总是能立即拿到锁,尽快访问数据。而对条件变量的期待则是,阻塞直到某个条件成立。所以我们一般使用条件变量,来控制某些流程的执行顺序。

最常见的应用是生产者-消费者模型。消费者线程pthread_cond_wait等待唤醒,避免轮询导致的性能浪费。生产者每新增一个任务,使用pthread_cond_signal唤醒消费者线程处理任务。

`void* producer(void* arg) {

for (int i = 0; i < 10; i++) {

pthread_mutex_lock(&mutex);

while (task_count == MAX_TASKS) {

pthread_cond_wait(&condition_var, &mutex);

}

task_queue[task_count++] = i;

printf("Producing task %d\n", i);

pthread_cond_signal(&condition_var);

pthread_mutex_unlock(&mutex);

}

return NULL;

}

void* consumer(void* arg) {

for (int i = 0; i < 10; i++) {

pthread_mutex_lock(&mutex);

while (task_count == 0) {

pthread_cond_wait(&condition_var, &mutex);

}

int task = task_queue[--task_count];

printf("Consuming task %d\n", task);

pthread_cond_signal(&condition_var);

pthread_mutex_unlock(&mutex);

}

return NULL;

}`

常见的任务队列就是基于FIFO队列、pthread条件变量等几个部分实现的高级同步原语。其提供的QUEUE_Read借由条件变量实现阻塞,不会导致线程忙等。这种用户态队列,相比低级同步原语,更有助于减少线程间通信的开销以及降低代码实现的复杂度。

信号量

信号量和条件变量使用思路相似,通常用来管理多个线程对共享资源的访问,控制并发线程数量。信号量具有一个计数器,可以通过对计数器的PV操作来对并发访问进行控制。

P操作(等待操作):当线程想要访问共享资源时,需要执行P操作。如果信号量计数器的值大于0,表示还有可用资源,线程可以继续执行并将计数器减一。如果计数器的值为0,表示没有可用资源,线程将进入等待状态,直到有资源可用。

V操作(释放操作):当线程使用完共享资源后,需要执行V操作来释放资源。V操作会将信号量计数器加一,表示有新的资源可用,同时唤醒可能正在等待的其他线程。

高性能的资源保护实践

虽然加锁操作在多线程的场景下可以充分保护临界资源,但是频繁的锁争用对于系统性能的影响也是很严重的。所以在多线程编程实现中,我们要考虑是否有lock-free的方法,如果不得不加锁又该怎么办。

Lock-free的方法

  • 原子变量

原子变量一般是由cpu提供的指令级别的支持,可以确保在多处理器或多核系统中执行时,原子操作能够在不被中断的情况下完成,从而保证多线程并发访问共享变量时的数据一致性。但一般只提供了简单算术指令,可以在一定程度上代替一些变量的加锁保护。

  • Rcu读锁

rcu读锁不同于传统读写锁的临界区操作,使用“读者临界区共享、写者延迟释放”的工作机制,实现了lock-free的高性能保护。同时rcu读锁不需要为各个临界区对象管理锁实体,对内存和编码也非常友好。当然由于写操作存在延迟,rcu读锁只适合读多写少的场景。

Rcu 的原理如下:在删除某个全局资源的瞬间,有多个读线程处于临界区,此时用更新后的空指针替换掉原始的资源,同时保留原始资源。那么此瞬间之后再访问资源的线程将获取空指针,而已经获取资源的线程仍可使用原始资源。当所有的临界访问结束,再行销毁,原始资源延迟释放的时间称为宽限期。那么宽限期是怎么确定的呢?系统怎么知道临界区的访问已经结束呢?继续往下看。

rcu.png

为什么转发平面代码没有rcu_read_lock,但是控制平面代码有?rcu的宽限期实质是等待所有cpu完成一次调度,对于抢占内核,需要rcu_read_lock关抢占,保证在临界区内不会发生抢占(当然编程者需要保证临界区内不阻塞),进而保证临界区内不发生调度。Dim会话的处理是不允许阻塞的,同时我们的内核禁止抢断,这也就保证了宽限期的要求。所以可以不使用 rcu_read_lock。

控制平面虽然也关抢占,不需要多此一举。但从编码角度来看,使用rcu_read_lock在语义上标记了原子操作(临界区)的范围,对代码的可读可维护性是有必要的。

另外rcu机制还提供了rcu_read_lock_bh。Bh锁是在临界区关抢占的同时关软中断,这样就进一步缩小了宽限期的范围,当所有cpu发生一次软中断,就已经不存在临界区冲突了。rcu_read_lock_bh适合在软中断多的场景下使用,一定程度上可以缩短宽限期范围,提升写操作的效率。

  • Per-cpu

每cpu变量基于每个cpu独立进行统计,在使用时再计算各cpu汇总的数值,统计时是高效的,但是汇总是低效的。适用于统计计数时不经常需要汇总的场景。

加锁的方法

Lock-free的方法的使用场景还是有局限性,对于更广泛的场景,还是需要用锁。

  • 加锁粒度

加锁粒度对于性能的影响是举足轻重的。加锁粒度和数据规模有关,一个过粗的锁保护大块数据—例如整个数据结构,一个精细的锁用来保护很小一块数据—例如数据结构中的某个成员变量。

例如一个链表,原始的方案是一个全局锁保护链表,但是在多cpu频繁并行访问这个链表时,这个锁就成为了性能的瓶颈--因为访问链表任意节点都需要加锁。如果为每个节点设计一个锁,这样只有在多cpu访问同一个节点时才会出现锁争用。

那问题来了,既然加锁越精细,锁争用越少,那岂不是可以进一步给节点中的每一个变量都设置一个锁?答案显然是否定的,如果加锁细粒度达到节点级别已经很好地优化了锁争用,那节点中的变量可能就并不存在频繁的锁争用了,这时候再去细化加锁,不但不能优化性能,反而浪费了大量资源给没有起到作用地锁,是很不划算的行为。

锁的细粒度要根据实际情况进行设计,过粗或者过细可能都会导致性能的下降,在设计之初可以是粗的,但是如果锁争用问题变得严重,设计就需要往更精细的加锁方向转化。

  • 睡眠锁or自旋锁

睡眠锁在获取锁失败时,会阻塞线程,触发上下文切换,避免忙等。自旋锁在获取锁失败时,会轮询锁状态,即忙等。

如果锁争用频繁的同时临界区较长,这时使用自旋锁,就会导致获取不到锁的线程忙等时间过长,浪费cpu性能。而使用睡眠锁主动把cpu让给其他可执行线程,得以充分利用cpu。

那在临界区较短的情况下呢?继续使用睡眠锁的话就容易产生这种问题:线程刚把cpu让出去,锁马上就被释放了,结构又轮到自己执行,又要切回来,这就导致系统会消耗大量的时间在上下文切换上。使用自旋锁的话,由于临界区短,忙等的时间就很短,这时候线程自旋相比来回切换上下文,效率就高多了。

刚接触v7内核时,曾有疑问为什么都使用的自旋锁?后来发现,转发线程是绑定cpu的(还有看门狗线程),使用睡眠锁触发调度只会把cpu让给看门狗,属于没有任何意义的浪费性能。

提到这一点是为了说明和加锁粒度一样的道理,多线程的设计还是要综合整体架构来具体分析,不能随意推断那种方法性能更好。

线程安全和可重入

线程安全和不可重入是两个相近的概念。

“线程安全”是指一段程序不使用共享资源(静态缓冲区、全局变量或者共享状态),或者是使用了共享资源但对共享资源进行同步的保护,继而在多线程环境下并行执行该段程序不会产生数据错误和不确定的行为。

“可重入”是指若一个程序可以“在任意时刻被中断,然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入的。也就是说,当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时,重新进入同一个子程序,仍然是安全的。

显然,线程不安全的接口一定是不可重入的。但是,线程安全的接口却不一定是可重入的,最经典的就是在信号上下文中使用malloc产生死锁的问题:查看glibc源码可以知道malloc操作是加锁的,是一个线程安全的函数。而一个进程正在执行malloc分配堆空间时,程序捕捉到信号发生中断,在信号处理程序中恰好也有一个malloc,此时的重入会获取同一把锁,形成死锁。

死锁

接着讲下死锁,死锁是多线程编程中非常常见的问题,形成线程死锁需要四个条件:互斥、请求与保持、不可剥夺和形成环路。理论上来说,只需要破坏一个就可以解决死锁问题。但是一般情况下,锁的设计即为了保护共享资源,所以前三个条件一般来说是必须的。避免死锁只有避免形成环路。

对于单个锁自环,即连续获取同一把锁导致的死锁。这种死锁出现的原因有:1、流程中遗忘释放锁;2、函数嵌套导致的连续加锁;3、上文提到的malloc死锁(中断上下文加锁)。

对于多把锁形成环路,即线程1持有A等B,线程2持有B等A,进而导致的死锁。避免形成环路最重要的方法是在编码过程严格控制加锁顺序,避免出现可能的环路。

用户态死锁发生时,可以gdb挂起异常进程,通过info threads查看各线程是否处在获取锁的上下文中,再进入这些线程,bt查看调用栈,分析死锁形成的具体原因。内核态死锁往往会导致更严重的问题,定位时同样需要在死锁现场获取调用栈信息进行分析。