31、多线程编程:从锁机制到C11内存模型的全面解析

多线程编程:从锁机制到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;
}

代码解释:

  1. 原子变量声明与初始化
    • atomic_int counter = ATOMIC_VAR_INIT(0); :声明并初始化一个原子整数变量 counter ,初始值为 0。使用 ATOMIC_VAR_INIT 宏确保初始化的正确性。
  2. 线程函数
    • void* increment_counter(void* arg) :该函数作为线程的执行体,在循环中对原子变量 counter 进行 100000 次原子递增操作。 atomic_fetch_add(&counter, 1); 会原子地将 counter 的值加 1,并返回旧值。
  3. 主函数
    • 创建两个线程 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 内存模型等多线程编程的关键技术,以及原子操作、内存顺序的使用方法。同时,还讨论了多线程编程的性能优化、调试与测试方法。在实际编程中,需要根据具体需求选择合适的技术和方法,确保程序的正确性和性能。通过合理的设计和优化,可以充分发挥多线程编程的优势,开发出高效、稳定的多线程应用程序。

内容概要:本文档是一份关于交换路由配置的学习笔记,系统地介绍了网络设备的远程管理、交换机与路由器的核心配置技术。内容涵盖Telnet、SSH、Console三种远程控制方式的配置方法;详细讲解了VLAN划分原理及Access、Trunk、Hybrid端口的工作机制,以及端口镜像、端口汇聚、端口隔离等交换技术;深入解析了STP、MSTP、RSTP生成树协议的作用与配置步骤;在路由部分,涵盖了IP地址配置、DHCP服务部署(接口池与全局池)、NAT转换(静态与动态)、静态路由、RIP与OSPF动态路由协议的配置,并介绍了策略路由和ACL访问控制列表的应用;最后简要说明了华为防火墙的安全区域划分与基本安全策略配置。; 适合人群:具备一定网络基础知识,从事网络工程、运维或相关技术岗位1-3年的技术人员,以及准备参加HCIA/CCNA等认证考试的学习者。; 使用场景及目标:①掌握企业网络中常见的交换与路由配置技能,提升实际操作能力;②理解VLAN、STP、OSPF、NAT、ACL等核心技术原理并能独立完成中小型网络搭建与调试;③通过命令示例熟悉华为设备CLI配置逻辑,为项目实施和故障排查提供参考。; 阅读建议:此笔记以实用配置为主,建议结合模拟器(如eNSP或Packet Tracer)动手实践每一条命令,对照拓扑理解数据流向,重点关注VLAN间通信、路由选择机制、安全策略控制等关键环节,并注意不同设备型号间的命令差异。
多旋翼无人机组合导航系统-多源信息融合算法(Matlab代码实现)内容概要:本文围绕多旋翼无人机组合导航系统,重点介绍了基于多源信息融合算法的设计与实现,利用Matlab进行代码开发。文中采用扩展卡尔曼滤波(EKF)作为核心融合算法,整合GPS、IMU(惯性测量单元)、里程计和电子罗盘等多种传感器数据,提升无人机在复杂环境下的定位精度与稳定性。特别是在GPS信号弱或丢失的情况下,通过IMU惯导数据辅助导航,实现连续可靠的位姿估计。同时,文档展示了完整的算法流程与Matlab仿真实现,涵盖传感器数据预处理、坐标系转换、滤波融合及结果可视化等关键环节,体现了较强的工程实践价值。; 适合人群:具备一定Matlab编程基础和信号处理知识,从事无人机导航、智能控制、自动化或相关领域研究的研究生、科研人员及工程技术人员。; 使用场景及目标:①应用于多旋翼无人机的高精度组合导航系统设计;②用于教学与科研中理解多传感器融合原理与EKF算法实现;③支持复杂环境下无人机自主飞行与定位系统的开发与优化。; 阅读建议:建议结合Matlab代码与理论推导同步学习,重点关注EKF的状态预测与更新过程、多传感器数据的时间同步与坐标变换处理,并可通过修改噪声参数或引入更多传感器类型进行扩展实验。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值