24、多线程编程中的共享内存:挑战与应对策略

多线程编程中的共享内存:挑战与应对策略

在多线程编程的世界里,共享内存是一个既关键又棘手的概念。当两个或多个异步指令序列访问同一数据时,该数据就被称为共享内存。对共享内存的访问必须精心协调,否则就可能导致数据损坏。在实时嵌入式系统的多线程编程风格中,尽量减少共享内存的使用、识别必须共享的数据并加以保护,是面临的重大挑战之一。

1. 异步与同步

“异步”意味着各个指令序列之间没有可预测的时间关系。一个序列可能只执行了部分指令就被提前中断,以便另一个序列的部分指令得以执行,而且两个序列之间的实际转换点是不可预测的。如果存在两个序列都必须访问的数据,那么程序在不同指令序列之间切换的自由度就必须受到限制,以使访问实现同步。

传统的桌面应用程序通常设计为单线程代码,只有中断服务例程(ISR)相对于应用程序异步执行。虽然在ISR和操作系统之间使用共享内存来缓冲数据,但依靠操作系统来同步访问。在应用程序内部,所有数据由单线程代码进行分配、初始化、使用和释放,这种方式使用的数据被称为线程私有数据。

然而,嵌入式软件通常是I/O密集型的,并且经常是多线程的,有很多使用共享内存的需求和机会。共享内存对于线程间或任务间的通信与协调至关重要,因此不能简单地消除其使用。但共享内存除了容易导致数据损坏外,还会增加程序的复杂性。线程设计得越独立于其他线程,就越容易设计、理解和调试。每次出现共享内存都会在线程之间建立一种新的关系,从而降低它们的独立性。

2. 识别共享对象

一个对象的数据是否可以共享,既不取决于其作用域,也不取决于其内存分配方法,而仅取决于对象的使用方式。有三种机制可以使对象的数据成为共享数据:
- 共享全局数据 :在多个线程的源代码中通过名称引用的全局对象是最容易识别的共享内存类型。无论程序是否为多线程,都应尽量减少全局对象的数量,因为全局对象会在函数之间建立联系,从而增加程序的复杂性。
- 共享私有数据 :如果一个线程将其私有对象的地址提供给另一个线程,那么该对象就可以被两个线程访问,不再是私有的。改变对象的作用域或内存分配方法都无法消除这种形式的共享内存。
- 共享函数 :被多个线程调用的函数称为共享函数,被共享函数调用的任何函数也都是共享函数。共享函数中引用的任何静态对象都是一种共享内存形式,无论对象的作用域是全局还是局部。由于对象只有一个实例,其内容会被所有调用引用该对象的函数的线程共享。大多数程序员不会期望函数的局部对象成为共享内存,但如果这些对象是静态分配的,并且函数被多个线程调用(直接或间接),就会发生共享,而且很难识别。要记住,库函数更有可能在多个线程中使用,任何被多个线程调用的函数都应被视为共享函数,因此确保它们不操作任何静态对象至关重要。

3. 可重入函数

在多线程软件中,多个不同的线程可能会同时执行。在某些系统中,从一个线程到另一个线程的上下文切换几乎可以在任何时候发生。如果在共享函数执行过程中发生上下文切换,该函数可能会被重新进入(类似于递归函数),即使不存在递归函数。

例如,线程A可能正在执行strstr库函数,却被线程B抢占。线程B可能在恢复线程A之前调用(甚至返回)strstr函数。在多线程程序中,被多个线程调用的函数必须为不应与其他线程共享的数据使用单独的线程特定实例。当函数内部仅使用自动或动态分配时,会自动实现这一点,从而创建一类可重入函数,这些函数本质上是线程安全的,因为每次进入函数都会创建其对象的新实例。然而,静态对象永远不可能是线程特定的,因为它们只有一个实例。

如果传递给可重入函数的参数碰巧是一个指针,这就创造了一种允许(但不强制)函数修改外部共享对象的情况。这并不意味着函数不再是可重入的,而只是函数使用方式的问题,必须检查调用以纠正任何问题。

4. 只读数据

并非所有共享数据对多线程程序复杂性的影响都相同。当共享数据被修改时会发生数据损坏,如果共享数据只被读取而不被写入,则不会发生数据损坏。常量表就是只读数据的一个例子。

随着程序的成熟,很难预测对象的使用方式会如何变化。程序修改可能会在未来添加修改曾经只读数据的代码。例如,曾经硬编码在程序中的配置数据,可能在程序初始化时从文件中加载。

至少,应该为任何共享只读数据添加注释,以便在查找共享数据问题的原因时能够忽略它。更好的方法是使用类型限定符“const”来声明对象,从语法上保护数据不被修改。

“const”关键字用于声明对象的值不能被程序修改,即其值是“只读”的。这提供了一种防御性编程方式,因为编译器会将任何直接修改对象的尝试报告为语法错误。创建const对象的声明语句必须包含初始值,因为不允许后续尝试设置其值。

与定义宏来表示常量值相比,const声明使数据类型更加明确:

#define AGE 35        /* "AGE" is merely text to be replaced    */ 
const int age = 35;   /* "age" is a signed int with value 35    */  

“const”关键字在指针声明中有很多有趣的用法,如下表所示:
| 声明 | 解释 |
| ---- | ---- |
| int total; | “total”是一个(可修改的)整数。 |
| const int year = 1999; | “year”是一个只读整数。 |
| const int *p; | “p”是一个(可修改的)指针,指向一个只读整数。 |
| int * const p = &total | “p”是一个只读指针,指向一个(可修改的)整数。 |
| const int * const p = &year | “p”是一个只读指针,指向一个只读整数。 |

需要注意的是,为对象添加“const”属性并不意味着其值不能被修改,这只是编译期间实施的语法限制,在程序执行期间仍有可能绕过此限制。

5. 应避免的编码实践

静态对象在共享函数中被修改时最容易出现问题。但即使在单线程调用的函数中,也应尽量避免修改静态对象,主要有以下两个原因:
- 不能仅仅通过检查函数是否在多个线程中被命名调用来确定它是否是共享函数。在多个线程中调用只是共享的充分条件,而非必要条件。因为当一个共享函数调用另一个函数时,第二个函数也会隐式地成为共享函数。
- 为了提高编程效率,我们会尽量复用现有代码。但这可能意味着以前只在单线程中使用的非共享函数,未来可能会在多个线程中使用。

虽然不能完全不使用静态对象来消除问题,但通过避免某些编码实践,可以消除最常见的问题。以下是两个修改局部静态对象的典型函数示例,应尽量避免:
- 在局部静态对象中保存内部状态的函数 :以strtok函数为例,第一次调用时,将指向句子的指针作为第一个参数传递给strtok;后续调用则传递空指针。可以发现,strtok内部必须保存初始指针的副本,并在后续调用中更新,以跟踪句子中的当前位置。为了在每次调用之间保留该指针的值,必须使用静态分配,这意味着只有一个指针副本。当多个线程尝试使用该函数时,会出现明显的问题。

strtok函数的示例代码如下:

char *strtok(char *string, char *delimeters)
{
    static char *state;
    char *beg, *end;
    if (string != NULL) state = string;
    if (state == NULL) return NULL;
    beg = state + strspn(state, delimeters);
    if (*beg == '\0') return NULL;
    end = strpbrk(beg, delimeters);
    if (end == NULL) state = NULL;
    else { *end++ = '\0'; state = end; }
    return beg;
}

可以看到,这个局部静态对象在多个线程调用strtok函数时会引发问题。而线程安全(可重入)版本的strtok(称为strtok_r)通过用调用者创建的线程特定对象替换局部静态指针来解决问题,示例代码如下:

char *strtok_r(char *string, char *delimeters, char **state)
{
    char *beg, *end;
    if (string != NULL) *state = string;
    if (*state == NULL) return NULL;
    beg = *state + strspn(*state, delimeters);
    if (*beg == '\0') return NULL;
    end = strpbrk(beg, delimeters);
    if (end == NULL) *state = NULL;
    else { *end++ = '\0'; *state = end; }
    return beg;
}
  • 返回局部静态对象地址的函数 :例如,下面的函数将字符串和整数组合成一个12字符的文件名,文件名存储在局部静态缓冲区中,并返回该缓冲区的地址:
char *Make_Filename(char *name, int version)
{
    static char fname_bfr[13];
    sprintf(fname_bfr, "%.8s.%03d", name, version);
    return fname_bfr;
}

假设调用此函数准备一个用于后续文件创建的文件名,如果在创建文件之前另一个线程调用了相同的函数,那么文件名就会被修改。问题在于所有线程共享同一个缓冲区,解决方案是让每个线程提供自己的单独缓冲区。一般有两种实现方式:
- 让线程通过将缓冲区地址作为额外参数传递给函数来提供缓冲区,示例代码如下:

char *Make_Filename(char *name, int version, char *fname_bfr)
{
    sprintf(fname_bfr, "%.8s.%03d", name, version);
    return fname_bfr;
}
- 从堆中分配缓冲区,示例代码如下:
char *Make_Filename(char *name, int version)
{
    char *fname_bfr = (char *) malloc(13);
    if (fname_bfr == NULL) return NULL;
    sprintf(fname_bfr, "%.8s.%03d", name, version);
    return fname_bfr;
}

从堆中分配缓冲区可以避免添加新的函数参数,但需要程序员在文件名不再需要时显式调用库函数free来释放空间。让调用者提供缓冲区更快,因为不需要搜索堆空间,并且可以保证在堆空间不足时函数不会失败。

标准C库中包含多个返回局部静态对象地址的函数,如ctime、asctime、localtime、gmtime、getenv、strerror和tmpnam。一些C编译器提供了这些函数的特殊版本,通常在原名称后面添加“r”(表示可重入),如ctime_r。

6. 访问共享内存

假设程序中存在无法避免的共享内存,例如用于缓冲输入设备ISR数据的队列,即使程序不是多线程的,该队列也是共享内存。那么队列数据结构究竟是如何损坏的?又如何同步对队列的访问以保护它呢?

考虑以下代码示例,左边是主程序中从队列中移除一个项目并递减其计数的指令序列,右边是ISR将数据输入队列并递增其计数的代码:

Main Program              ISR
pointer2q->count++ ;      LDR R0,pointer2q
LDR R0,pointer2q         LDR R1,[R0,#count_offset]
LDR R1,[R0,#count_offset]...
...                      LDR R0,pointer2q
                         LDR R1,[R0,#count_offset]
                         ADD R1,R1,#1
                         STR R1,[R0,#count_offset]
SUB R1,R1,#1
STR R1,[R0,#count_offset]
pointer2q->count-- ;

如果ISR在主程序递减计数的指令序列中间中断了主程序,那么ISR递增后的计数在返回主程序时会被覆盖,程序将不知道队列中添加了一个新项目。

例如,假设队列中项目的初始计数为10。主程序从队列中移除一个项目后开始递减计数,将旧计数(10)加载到寄存器R1中,但在进一步操作之前被ISR中断。主程序的所有寄存器(包括R1)在进入ISR时被保存到堆栈中。然后ISR向队列中插入一个新项目,将队列计数从10递增到11,恢复主程序的寄存器(包括R1中的值10)并返回。主程序继续执行,将R1递减到9并存储到计数中,从而覆盖了ISR设置的11。实际上有一次队列插入和一次删除操作,最终队列计数应该仍然是10,但由于共享内存冲突,计数变为了9。

处理器架构的影响

上述示例通常仅适用于像ARM处理器这样的加载/存储架构。其他处理器通常有递增和递减指令,可以在单条指令中更新计数,而不是像示例中那样使用序列。由于中断只在指令之间发生,计数的递减将在识别中断之前完成。然而,如果计数保存在32位整数中(如IBM - PC中的定时器滴答计数),而处理器寄存器只有8位或16位宽,那么递增或递减计数可能需要多条指令。一般来说,任何多指令的递增或递减序列都必须防止被中断。因此,在递增或递减操作之前必须禁用中断,操作完成后再重新启用,示例代码如下:

extern long shared_counter ;
disable() ;
shared_counter++ ;
enable() ; 

中断不仅可能发生在两行C代码之间,还可能发生在任何两条机器指令之间。即使是最简单的C代码行也可能产生多条机器指令,尤其是当涉及的操作数大小超过单条指令的处理能力时。对于16位处理器,这包括长整型、指针、任何浮点数据和大多数结构体;对于8位处理器,还需要考虑所有16位操作数,如短整型。为了避免这些差异带来的问题,一个好的编程实践是保护对任何共享内存的访问,无论其大小如何。不过,使用32位处理器显然会使编程更简单。

ISR并不是异步执行的代码序列的唯一例子。在嵌入式软件中,抢占式实时内核很常见,它们使用ISR作为触发点,在多线程应用程序中从一个线程切换到另一个线程。当发生中断时,ISR可能返回一个与被中断线程不同的线程。由于中断可以在任何线程执行的任何点发生,所有线程相对于其他线程都是异步的。因此,即使对象不被任何ISR访问,也必须关注线程之间共享的对象。

综上所述,在多线程编程中,共享内存的管理是一个复杂而重要的问题。我们需要深入理解共享内存的各种形式、可能出现的问题以及相应的解决方法,才能编写出稳定、高效的多线程程序。

多线程编程中的共享内存:挑战与应对策略

7. 共享内存访问问题的深入分析

为了更清晰地理解共享内存访问问题,我们可以通过一个 mermaid 流程图来展示上述示例中主程序和 ISR 操作队列计数时的流程:

graph LR
    classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
    classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px

    A([主程序开始]):::startend --> B(加载队列计数到 R1):::process
    B --> C{是否被 ISR 中断?}:::decision
    C -->|是| D([ISR 开始]):::startend
    D --> E(保存主程序寄存器到堆栈):::process
    E --> F(插入新项目到队列):::process
    F --> G(递增队列计数):::process
    G --> H(恢复主程序寄存器):::process
    H --> I([ISR 结束]):::startend
    I --> J(主程序继续递减 R1):::process
    J --> K(存储 R1 到队列计数):::process
    C -->|否| L(主程序递减 R1):::process
    L --> K

    K --> M([主程序结束]):::startend

从这个流程图中可以更直观地看到,当主程序在操作队列计数时被 ISR 中断,就可能导致共享内存冲突,使得最终的队列计数出现错误。

8. 同步机制的重要性及实现方式

为了避免共享内存冲突,我们需要使用同步机制来协调对共享内存的访问。常见的同步机制有互斥锁、信号量等。

互斥锁

互斥锁是一种最基本的同步机制,它可以确保同一时间只有一个线程能够访问共享资源。以下是一个使用互斥锁来保护队列计数的示例代码:

#include <pthread.h>

// 定义互斥锁
pthread_mutex_t queue_mutex;

// 队列结构体
typedef struct {
    int count;
    // 其他队列相关成员
} Queue;

Queue queue;

// 主程序中移除项目并递减计数的函数
void main_program_remove_item() {
    // 加锁
    pthread_mutex_lock(&queue_mutex);
    queue.count--;
    // 解锁
    pthread_mutex_unlock(&queue_mutex);
}

// ISR 中插入项目并递增计数的函数
void isr_insert_item() {
    // 加锁
    pthread_mutex_lock(&queue_mutex);
    queue.count++;
    // 解锁
    pthread_mutex_unlock(&queue_mutex);
}

int main() {
    // 初始化互斥锁
    pthread_mutex_init(&queue_mutex, NULL);

    // 模拟主程序和 ISR 操作
    main_program_remove_item();
    // 模拟 ISR 被触发
    isr_insert_item();

    // 销毁互斥锁
    pthread_mutex_destroy(&queue_mutex);

    return 0;
}

在这个示例中,我们使用 pthread_mutex_lock pthread_mutex_unlock 函数来加锁和解锁互斥锁,确保在操作队列计数时不会发生共享内存冲突。

信号量

信号量是另一种常用的同步机制,它可以控制对共享资源的访问数量。以下是一个使用信号量来保护队列的示例代码:

#include <semaphore.h>
#include <pthread.h>

// 定义信号量
sem_t queue_semaphore;

// 队列结构体
typedef struct {
    int count;
    // 其他队列相关成员
} Queue;

Queue queue;

// 主程序中移除项目的函数
void main_program_remove_item() {
    // 等待信号量
    sem_wait(&queue_semaphore);
    queue.count--;
    // 释放信号量
    sem_post(&queue_semaphore);
}

// ISR 中插入项目的函数
void isr_insert_item() {
    // 等待信号量
    sem_wait(&queue_semaphore);
    queue.count++;
    // 释放信号量
    sem_post(&queue_semaphore);
}

int main() {
    // 初始化信号量,初始值为 1 表示只有一个线程可以访问队列
    sem_init(&queue_semaphore, 0, 1);

    // 模拟主程序和 ISR 操作
    main_program_remove_item();
    // 模拟 ISR 被触发
    isr_insert_item();

    // 销毁信号量
    sem_destroy(&queue_semaphore);

    return 0;
}

在这个示例中,我们使用 sem_wait sem_post 函数来等待和释放信号量,确保在操作队列时不会发生共享内存冲突。

9. 多线程编程的最佳实践总结

为了编写出高质量的多线程程序,我们可以总结以下最佳实践:
- 减少共享内存的使用 :尽量减少全局对象和静态对象的使用,将数据封装在各个线程内部,减少线程之间的依赖。
- 识别共享对象 :仔细分析程序,识别出所有可能的共享对象,并采取相应的保护措施。
- 使用可重入函数 :确保被多个线程调用的函数是可重入的,避免使用静态对象。
- 保护只读数据 :对于只读数据,使用 const 关键字进行保护,避免意外修改。
- 避免不良编码实践 :避免在函数中使用局部静态对象保存内部状态或返回局部静态对象的地址。
- 使用同步机制 :使用互斥锁、信号量等同步机制来协调对共享内存的访问,确保数据的一致性。

10. 总结与展望

在多线程编程中,共享内存的管理是一个复杂而关键的问题。我们需要深入理解共享内存的各种形式、可能出现的问题以及相应的解决方法。通过合理使用同步机制、遵循最佳实践,我们可以编写出稳定、高效的多线程程序。

随着计算机技术的不断发展,多线程编程在各个领域的应用越来越广泛。未来,我们可能会面临更加复杂的多线程场景,例如多核处理器、分布式系统等。在这些场景下,共享内存的管理将变得更加困难,需要我们不断探索和创新,以应对新的挑战。

同时,随着编程语言和开发工具的不断进步,也会为我们提供更多更好的多线程编程支持。例如,一些现代编程语言提供了内置的并发库和高级同步机制,使得多线程编程更加简单和安全。我们应该密切关注这些技术的发展,不断提升自己的多线程编程能力。

总之,多线程编程中的共享内存管理是一个需要长期学习和实践的领域,只有不断积累经验,才能编写出高质量的多线程程序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值