多线程编程:从锁机制到C11内存模型的全面解析
1. 自旋锁(Spinlock)
自旋锁是一种用于多线程编程的同步机制,其核心思想是让线程在等待锁的过程中不断循环检查锁的状态,而不是进入睡眠状态。以下是自旋锁的简单伪代码实现:
while ( locked == true ) {
/* do nothing */
}
locked = true;
在这段代码中,
locked
是一个标志变量,用于表示锁是否被其他线程占用。如果锁被占用,当前线程会不断循环检查
locked
的值,直到其变为
false
,然后将其设置为
true
以获取锁。
1.1 自旋锁的优缺点
- 优点 :当预期等待时间非常短时,自旋锁可以提高性能,因为线程不需要进行上下文切换,避免了切换带来的开销。
- 缺点 :自旋锁会浪费 CPU 时间并增加功耗,因为线程在循环等待时会一直占用 CPU 资源。
1.2 适用场景
自旋锁只适用于多核和多处理器系统。在单核系统中,使用自旋锁是没有意义的,因为当一个线程进入自旋锁的循环时,由于只有一个核心,其他线程无法执行,最终调度器会让当前线程进入睡眠状态,这就导致了 CPU 资源的浪费。
1.3 实现挑战
实现一个快速且正确的自旋锁并非易事,需要考虑以下问题:
- 是否需要在加锁和/或解锁时使用内存屏障?如果需要,应该使用哪种内存屏障?例如,在 Intel 64 架构中,有
lfence
、
sfence
和
mfence
等内存屏障指令。
- 如何确保标志变量的修改是原子操作?在 Intel 64 架构中,可以使用
xchg
指令(在多处理器系统中需要添加
lock
前缀)来实现原子操作。
1.4 相关函数
pthreads
提供了一套精心设计且可移植的自旋锁机制。可以参考以下函数的手册页获取更多信息:
-
pthread_spin_lock
-
pthread_spin_destroy
-
pthread_spin_unlock
2. 信号量(Semaphores)
信号量是一个共享的整数变量,可对其执行三种操作:
-
初始化
:使用参数
N
进行初始化,将信号量的值设置为
N
。
-
等待(Wait)
:如果信号量的值不为零,则将其减 1;否则,线程会等待,直到有其他线程将其值增加,然后再进行减 1 操作。
-
释放(Post)
:将信号量的值加 1。
信号量的值不能小于 0。以下是一个信号量使用的示例代码:
#include <semaphore.h>
#include <inttypes.h>
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
sem_t sem;
uint64_t counter1 = 0;
uint64_t counter2 = 0;
pthread_t t1, t2, t3;
void* t1_impl( void* _ ) {
while( counter1 < 10000000 ) counter1++;
sem_post( &sem );
return NULL;
}
void* t2_impl( void* _ ) {
while( counter2 < 20000000 ) counter2++;
sem_post( &sem );
return NULL;
}
void* t3_impl( void* _ ) {
sem_wait( &sem );
sem_wait( &sem );
printf("End: counter1 = %" PRIu64 " counter2 = %" PRIu64 "\n",
counter1, counter2 );
return NULL;
}
int main(void) {
sem_init( &sem, 0, 0 );
pthread_create( &t3, NULL, t3_impl, NULL );
sleep( 1 );
pthread_create( &t1, NULL, t1_impl, NULL );
pthread_create( &t2, NULL, t2_impl, NULL );
sem_destroy( &sem );
pthread_exit( NULL );
return 0;
}
2.1 代码解释
-
sem_init函数用于初始化信号量,第二个参数为标志,0 表示进程本地信号量,可由不同线程使用;非零值表示多进程可见的信号量。第三个参数设置信号量的初始值。 -
sem_destroy函数用于删除信号量。 -
线程
t1和t2分别递增计数器counter1和counter2,然后调用sem_post函数增加信号量的值。 -
线程
t3调用sem_wait函数两次,将信号量的值减 2,当信号量被其他线程增加两次后,t3会打印计数器的值。 -
pthread_exit调用确保主线程不会过早终止,直到所有其他线程完成工作。
2.2 信号量的应用场景
-
限制同时执行某段代码的进程数量不超过
n。 - 使一个线程等待另一个线程完成特定操作,从而对线程的操作进行排序。
- 限制并行执行某项任务的工作线程数量,避免过多线程导致性能下降。
2.3 信号量与互斥锁的区别
信号量与互斥锁并不完全等同。互斥锁只能由加锁的线程解锁,而信号量可以由任何线程自由修改。
2.4 相关函数
可以参考以下函数的手册页获取更多关于信号量的信息:
-
sem_close
-
sem_destroy
-
sem_getvalue
-
sem_init
-
sem_open
-
sem_post
-
sem_unlink
-
sem_wait
3. Intel 64 的内存模型
Intel 64 通常被认为是一种较强的内存模型,在大多数情况下,它能保证满足以下约束:
- 存储操作不会与旧的存储操作重排序。
- 存储操作不会与旧的加载操作重排序。
- 加载操作不会与其他加载操作重排序。
- 在多处理器系统中,对同一位置的存储操作具有总顺序。
然而,也存在一些例外情况:
- 使用
movntdq
等指令绕过缓存进行内存写入时,可能会与其他存储操作重排序。
- 像
rep movs
这样的字符串指令可能会与其他存储操作重排序。
3.1 内存重排序示例
以下是一个演示硬件内存重排序的示例代码:
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdlib.h>
#include <time.h>
sem_t sem_begin0, sem_begin1, sem_end;
int x, y, read0, read1;
void *thread0_impl( void *param )
{
for (;;) {
sem_wait( &sem_begin0 );
x = 1;
// This only disables compiler reorderings:
asm volatile("" ::: "memory");
// The following line disables also hardware reorderings:
// asm volatile("mfence" ::: "memory");
read0 = y;
sem_post( &sem_end );
}
return NULL;
};
void *thread1_impl( void *param )
{
for (;;) {
sem_wait( &sem_begin1 );
y = 1;
// This only disables compiler reorderings:
asm volatile("" ::: "memory");
// The following line disables also hardware reorderings
// asm volatile("mfence" ::: "memory");
read1 = x;
sem_post( &sem_end );
}
return NULL;
};
int main( void ) {
sem_init( &sem_begin0, 0, 0);
sem_init( &sem_begin1, 0, 0);
sem_init( &sem_end, 0, 0);
pthread_t thread0, thread1;
pthread_create( &thread0, NULL, thread0_impl, NULL);
pthread_create( &thread1, NULL, thread1_impl, NULL);
for (uint64_t i = 0; i < 100000; i++)
{
x = 0;
y = 0;
sem_post( &sem_begin0 );
sem_post( &sem_begin1 );
sem_wait( &sem_end );
sem_wait( &sem_end );
if (read0 == 0 && read1 == 0 ) {
printf( "reordering happened on iteration %" PRIu64 "\n", i );
exit(0);
}
}
puts("No reordering detected during 100000 iterations");
return 0;
}
3.2 代码解释
-
主函数的执行步骤如下:
1. 初始化线程和两个起始信号量以及一个结束信号量。
2. 将x和y初始化为 0。
3. 通知线程开始执行事务。
4. 等待两个线程完成事务。
5. 检查是否发生了内存重排序。当read0和read1都为 0 时,表示发生了重排序。
6. 如果检测到内存重排序,程序会输出信息并退出;否则,重复步骤 2 最多 100000 次。
3.3 解决内存重排序问题
为了解决内存重排序问题,可以添加
mfence
指令。将编译器屏障替换为完整的内存屏障
asm volatile( "mfence":::"memory");
可以完全消除重排序问题。
4. 无锁编程(Lock-Free Programming)
无锁编程是一种确保在多线程环境中安全操作共享数据而不使用互斥锁的技术。代码被认为是无锁的,需要满足以下两个条件:
- 不使用互斥锁。
- 系统不会无限期锁定,包括避免活锁。
4.1 无锁编程的挑战
- 重排序 :在没有互斥锁的情况下,需要明确指定内存屏障的位置,以避免不必要的性能损失。
- 非原子操作 :在没有互斥锁的保护下,只有少数操作是原子的。在大多数现代处理器上,自然对齐的原生类型的读写操作是原子的,但对于大于 8 字节的读写操作,在 Intel 64 架构中没有原子性保证。其他内存交互通常是非原子的,例如 SSE 指令的 16 字节读写、字符串操作等。
4.2 比较并交换(Compare-and-Swap,CAS)
为了在不使用互斥锁的情况下安全地执行复杂操作,工程师发明了比较并交换(CAS)操作。CAS 指令可以看作是一个原子操作序列,其等效的 C 函数如下:
bool cas(int* p , int old, int new) {
if (*p != old) return false;
*p = new;
return true;
}
以下是一个使用 CAS 实现原子加法的示例代码:
int add(int* p, int add ) {
bool done = false;
int value;
while (!done) {
value = *p;
done = cas(p, value, value + add );
}
return value + add;
}
4.3 Intel 64 中的 CAS 指令
Intel 64 实现了
cmpxchg
、
cmpxchg8b
和
cmpxchg16b
等 CAS 指令。在多处理器系统中,这些指令需要添加
lock
前缀。
4.4 无锁编程的建议
建议使用标准兼容的方式进行比较并设置操作(以及操作原子变量),以避免编写不可移植的代码。当需要执行复杂的原子操作时,建议使用互斥锁或参考专家实现的无锁数据结构。
5. C11 内存模型
5.1 概述
C11 内存模型相对较弱,它只保证数据依赖顺序,在某些分类中属于具有依赖顺序的弱内存模型。与 Intel 64 不同,为了编写可移植的代码,不能假设代码将在较强的架构上执行,原因如下:
- 当为其他较弱的架构重新编译代码时,由于硬件重排序的工作方式不同,程序的行为会发生变化。
- 当为同一架构重新编译代码时,编译器可能会进行不违反标准弱顺序规则的重排序,这可能会改变程序的行为。
5.2 原子类型(Atomics)
C11 引入了原子类型,可用于编写快速的多线程程序。要使用原子类型,需要包含
<stdatomic.h>
头文件。原子类型可以原子地修改,在某些情况下,允许对共享数据进行线程安全的操作,而无需使用互斥锁。
5.2.1 原子类型的声明
可以使用
_Atomic()
类型说明符声明原子整数,例如:
_Atomic(int) counter;
也可以直接使用原子类型,例如:
atomic_int counter;
5.2.2 原子变量的初始化
原子局部变量不应直接初始化,而应使用
ATOMIC_VAR_INIT
宏。全局原子变量会被保证处于正确的初始状态。如果需要在变量声明后进行初始化,可以使用
atomic_init
宏。示例代码如下:
void f(void) {
/* Initialization during declaration */
atomic_int x = ATOMIC_VAR_INIT( 42 );
atomic_int y;
/* initialization later */
atomic_init( &y, 42 );
}
5.3 内存顺序(Memory Orderings)
C11 中的内存顺序由以下枚举常量描述(按严格程度递增):
| 内存顺序 | 描述 |
| — | — |
|
memory_order_relaxed
| 最弱的模型,只要不改变单线程程序的可观察行为,任何内存重排序都是允许的。 |
|
memory_order_consume
|
memory_order_acquire
的较弱版本。 |
|
memory_order_acquire
| 加载操作具有获取语义。 |
|
memory_order_release
| 存储操作具有释放语义。 |
|
memory_order_acq_rel
| 结合了获取和释放语义。 |
|
memory_order_seq_cst
| 所有标记为该顺序的操作都不会进行内存重排序,无论引用的是哪个原子变量。 |
5.4 原子变量的操作
可以对原子变量执行以下操作:
void atomic_store(volatile _Atomic(T)* object, T value);
T atomic_load(volatile _Atomic(T)* object);
T atomic_exchange(volatile _Atomic(T)* object, desired);
T atomic_fetch_add(volatile _Atomic(T)* object, U operand);
T atomic_fetch_sub(volatile _Atomic(T)* object, U operand);
T atomic_fetch_or (volatile _Atomic(T)* object, U operand);
T atomic_fetch_xor(volatile _Atomic(T)* object, U operand);
T atomic_fetch_and(volatile _Atomic(T)* object, U operand);
bool atomic_compare_exchange_strong(
volatile _Atomic(T)* object, T * expected, T desired);
bool atomic_compare_exchange_weak(
volatile _Atomic(T)* object, T * expected, T desired);
这些操作都有显式和隐式两种版本。显式版本接受一个额外的参数,用于描述内存顺序;隐式版本默认使用最强的内存顺序(顺序一致性)。
5.5 布尔共享标志
布尔共享标志有一个特殊的类型
atomic_flag
,它有两种状态:设置和清除。对它的操作保证是原子的,无需使用锁。可以使用
ATOMIC_FLAG_INIT
宏初始化标志,相关函数有
atomic_flag_test_and_set
和
atomic_flag_clear
,它们都有接受内存顺序描述的显式版本。
综上所述,多线程编程涉及到多种同步机制和内存模型,每种机制都有其适用场景和挑战。在实际编程中,需要根据具体需求选择合适的技术,以确保程序的正确性和性能。
5.6 原子操作相关代码示例及解释
下面通过一个简单的示例代码,来进一步理解原子操作的使用:
#include <stdio.h>
#include <stdatomic.h>
#include <pthread.h>
atomic_int counter = ATOMIC_VAR_INIT(0);
void* increment_counter(void* arg) {
for (int i = 0; i < 100000; i++) {
atomic_fetch_add(&counter, 1);
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, increment_counter, NULL);
pthread_create(&thread2, NULL, increment_counter, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Final counter value: %d\n", atomic_load(&counter));
return 0;
}
代码解释:
-
原子变量声明与初始化
:
-
atomic_int counter = ATOMIC_VAR_INIT(0);:声明并初始化一个原子整数变量counter,初始值为 0。使用ATOMIC_VAR_INIT宏确保初始化的正确性。
-
-
线程函数
:
-
void* increment_counter(void* arg):该函数作为线程的执行体,在循环中对原子变量counter进行 100000 次原子递增操作。atomic_fetch_add(&counter, 1);会原子地将counter的值加 1,并返回旧值。
-
-
主函数
:
-
创建两个线程
thread1和thread2,并让它们执行increment_counter函数。 -
使用
pthread_join等待两个线程执行完毕。 -
最后使用
atomic_load(&counter)读取原子变量counter的最终值并输出。
-
创建两个线程
5.7 内存顺序的选择
在实际编程中,选择合适的内存顺序非常重要,不同的内存顺序会影响程序的性能和正确性。以下是一些选择建议:
-
memory_order_relaxed
:当对操作的顺序没有严格要求,只关心操作的原子性时,可以使用该内存顺序。例如,在统计并发操作的次数时,只需要保证计数器的原子更新,而不需要考虑操作的顺序。
-
memory_order_seq_cst
:这是默认的内存顺序,提供了最强的顺序保证,但可能会带来一定的性能开销。当需要确保操作的顺序一致性,避免出现意外的重排序时,可以使用该内存顺序。
-
其他内存顺序
:
memory_order_acquire
、
memory_order_release
和
memory_order_acq_rel
通常用于实现更复杂的同步机制,例如在生产者 - 消费者模型中,生产者线程使用
memory_order_release
存储数据,消费者线程使用
memory_order_acquire
加载数据,以确保数据的可见性和操作的顺序。
6. 多线程编程的性能优化
6.1 减少锁的使用
锁的使用会带来上下文切换和线程阻塞的开销,因此在多线程编程中,应尽量减少锁的使用。可以采用无锁编程技术,如前面介绍的 CAS 操作,来实现对共享数据的安全访问。同时,合理设计数据结构和算法,将共享数据划分为多个独立的部分,减少线程之间的竞争。
6.2 合理使用线程池
线程的创建和销毁会带来一定的开销,尤其是在频繁创建和销毁线程的场景下。使用线程池可以避免这种开销,提高程序的性能。线程池会预先创建一定数量的线程,当有任务到来时,从线程池中获取空闲线程来执行任务,任务执行完毕后,线程不会被销毁,而是返回线程池等待下一个任务。
6.3 避免虚假共享
虚假共享是指多个线程同时访问位于同一缓存行的不同变量,导致缓存行频繁失效,从而影响程序的性能。为了避免虚假共享,可以对变量进行合理的对齐和填充,确保不同线程访问的变量位于不同的缓存行。例如,在 Intel 64 架构中,缓存行的大小通常为 64 字节,可以将变量按照 64 字节对齐。
以下是一个简单的避免虚假共享的示例代码:
#include <stdio.h>
#include <pthread.h>
#define CACHE_LINE_SIZE 64
typedef struct {
int value;
char padding[CACHE_LINE_SIZE - sizeof(int)];
} AlignedInt;
AlignedInt counter1, counter2;
void* increment_counter1(void* arg) {
for (int i = 0; i < 100000; i++) {
counter1.value++;
}
return NULL;
}
void* increment_counter2(void* arg) {
for (int i = 0; i < 100000; i++) {
counter2.value++;
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, increment_counter1, NULL);
pthread_create(&thread2, NULL, increment_counter2, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Counter 1 value: %d\n", counter1.value);
printf("Counter 2 value: %d\n", counter2.value);
return 0;
}
代码解释:
-
结构体定义
:
AlignedInt结构体包含一个整数value和一个填充数组padding,填充数组的大小使得结构体的总大小为 64 字节,确保counter1和counter2位于不同的缓存行。 -
线程函数
:分别对
counter1和counter2进行递增操作。 - 主函数 :创建两个线程并等待它们执行完毕,最后输出计数器的值。
6.4 优化内存访问模式
合理的内存访问模式可以提高缓存命中率,从而提高程序的性能。尽量使线程访问连续的内存区域,避免随机访问。例如,在处理数组时,按照数组的顺序进行访问,而不是随机跳跃访问。
7. 多线程编程的调试与测试
7.1 调试工具
在多线程编程中,调试是一项具有挑战性的任务,因为线程之间的交互和并发问题可能会导致难以复现的错误。以下是一些常用的调试工具:
-
GDB
:GDB 是一个强大的调试器,可以用于调试多线程程序。它支持查看线程的状态、堆栈信息,设置断点等功能。例如,可以使用
info threads
命令查看当前所有线程的信息,使用
thread
命令切换到指定线程进行调试。
-
Valgrind
:Valgrind 可以检测内存泄漏、越界访问等问题。对于多线程程序,它可以帮助发现线程安全相关的内存问题。
-
Thread Sanitizer
:Thread Sanitizer 是一种动态分析工具,可以检测线程间的数据竞争问题。它会在程序运行时记录线程的访问信息,当发现数据竞争时,会输出详细的错误信息。
7.2 测试方法
为了确保多线程程序的正确性,需要进行充分的测试。以下是一些测试方法:
-
单元测试
:对每个线程函数进行单独的测试,确保其功能的正确性。可以使用测试框架如 Google Test 来编写单元测试。
-
压力测试
:在高并发场景下对程序进行测试,模拟大量线程同时访问共享资源的情况,检查程序是否会出现性能下降、死锁等问题。
-
随机测试
:随机生成不同的输入和线程调度顺序,测试程序在各种情况下的稳定性。
7.3 调试与测试流程
以下是一个简单的调试与测试流程:
graph TD;
A[编写代码] --> B[单元测试];
B --> C{是否通过};
C -- 是 --> D[压力测试];
C -- 否 --> E[调试代码];
E --> B;
D --> F{是否通过};
F -- 是 --> G[随机测试];
F -- 否 --> E;
G --> H{是否通过};
H -- 是 --> I[发布];
H -- 否 --> E;
8. 总结
多线程编程是提高程序性能和并发处理能力的重要手段,但同时也带来了许多挑战,如线程同步、内存重排序、数据竞争等问题。本文介绍了自旋锁、信号量、Intel 64 内存模型、无锁编程、C11 内存模型等多线程编程的关键技术,以及原子操作、内存顺序的使用方法。同时,还讨论了多线程编程的性能优化、调试与测试方法。在实际编程中,需要根据具体需求选择合适的技术和方法,确保程序的正确性和性能。通过合理的设计和优化,可以充分发挥多线程编程的优势,开发出高效、稳定的多线程应用程序。
超级会员免费看
19

被折叠的 条评论
为什么被折叠?



