19、并发软件编程全解析:从前后台系统到多线程编程

并发软件编程全解析:从前后台系统到多线程编程

1. 前后台系统

在许多对响应时间要求极高的系统中,将程序组织为前后台系统是一种常见的做法。

1.1 前后台系统的基本结构

前台负责执行大部分实际工作,由一个或多个中断服务例程(ISR)实现,每个 ISR 处理特定的硬件事件。这种安排使系统能够以可预测的延迟响应外部事件。若外部事件相互独立,各 ISR 之间可能几乎没有通信。
主程序完成必要的初始化后,进入后台部分,通常只是一个简单的循环,等待中断发生。以下是前后台系统的组织流程图:

graph TD;
    A[Start] --> B[Initialize];
    B --> C[Wait for Interrupt];
    C --> D{Interrupt};
    D --> E[ISR for Task #1];
    D --> F[ISR for Task #2];
    D --> G[ISR for Task #3];
    E --> C;
    F --> C;
    G --> C;

需要注意的是,大多数嵌入式应用一旦启动就会无限运行,所以图中未显示程序终止路径。

1.2 线程状态与序列化

先前发生的事件有时会决定如何处理输入事件。例如,向外部设备发送数据请求,设备会返回一组数据值。每个数据字节可能与特定测量相关,仅通过到达顺序区分。若每个字节引发新的中断请求,正确处理数据就要求相应的 ISR 提供一个计数器。该计数器在发出原始命令时重置为零,每次输入中断时递增。以下是相关代码示例:

unsigned int byte_counter;
void Send_Request_For_Data(void)
{
    byte_counter = 0;
    outportb(CMD_PORT, RQST_DATA_CMD);
}
void Process_One_Data_Byte(void)
{
    BYTE8 data = inportb(DATA_PORT);
    switch (++byte_counter)
    {
        case 1: Process_Temperature(data); break;
        case 2: Process_Altitude(data); break;
        case 3: Process_Humidity(data); break;
        ...
    }
}

这个计数器保存状态信息,使我们能根据接收顺序区分数据字节。

1.3 延迟管理

如果 ISR 实现的任务要完成与外部事件相关的所有工作,可能需要处理大量任务。此时,在硬件中实现中断优先级系统,并在每个 ISR 开始时尽快重新启用高优先级中断就显得尤为重要。但要记住,只要处于 ISR 中,低优先级中断就会一直被禁用。若 ISR 包含大量代码,且直到 ISR 结束才重新启用低优先级中断,其延迟时间可能会让人无法忍受。
例如,处理输入设备的 ISR 可能需要处理输入数据并将结果发送到输出设备。但在输出设备准备好之前,无法进行写入操作。若在 ISR 内轮询输出设备的就绪状态,任何挂起的低优先级中断都需要等待不确定的时间,这显然是不可接受的。
一种解决方案是让 ISR 将输出数据写入队列,后台任务从队列中取出数据。这样,ISR 就变成了直线代码,对低优先级中断施加更可预测的延迟。
使用中断驱动的输入和轮询等待循环输出是一种直观的解决方案,但这种系统的整体响应时间远非最优,因为后台循环可能会被最低优先级的中断暂停。通过对输入和输出都使用中断驱动的输入/输出(I/O),可以实现更快的响应时间。
完全中断驱动架构的一般结构如下:

graph TD;
    A[Input Ready] --> B[Input Data];
    B --> C[Process Data];
    C --> D{Output Ready};
    D --> E[FIFO Queue];
    E --> F[Output Data];
    C --> E;
    D --> G[Dequeue Data];
    G --> F;

使用队列时,有一个小细节需要解决。输出设备处理完当前输出字节后会请求中断,表明已准备好接收下一个字节。这会产生两个问题:
1. 当输入 ISR 将第一个数据放入先进先出(FIFO)队列时,输出设备处于空闲的就绪状态,不会发出中断请求,输出 ISR 不会被调用,数据也不会从队列中移除。
2. 若输出设备在队列空时变为就绪状态,后续放入队列的下一个数据将不会有中断来移除。
假设可以确定输出设备是否正在处理数据字节,可通过设备状态端口或软件忙标志获取此信息。除了重新启用中断和清除标志外,将输出 ISR 的其余部分替换为调用实际输出数据的过程(如 SendData)。输入 ISR 每次向队列写入数据时检查输出忙标志,若设备忙,等待设备就绪中断;否则,调用 SendData 启动输出过程。以下是该过程的流程图:

graph TD;
    A[Input Ready] --> B{Output Device Busy?};
    B -- No --> C[SendData Subroutine];
    B -- Yes --> D[Input Data];
    D --> E[Process Data];
    E --> F[Enqueue Data];
    F --> G[FIFO Queue];
    G --> H[Dequeue Data];
    H --> I[Output Data];
    C --> F;
1.4 中断溢出

在输入就绪的 ISR 中,如果所有重要工作都在 ISR 中完成,“处理数据”部分可能对应一个冗长的计算,导致相同或更低优先级的中断长时间被禁用。这种情况称为中断溢出,需要考虑处理器和算法是否能满足外部设备的数据速率要求。不过,偶尔错过一个或多个中断可能并非灾难性的。例如,从缓慢变化的模拟信号中定期采样时,错过的中断通常可以通过对前一个样本和下一个样本求平均值来替代。

1.5 将工作移至后台

减少延迟的一种明显方法是将任何非时间关键的工作(如更新显示)移至后台线程。前台 ISR 将待处理的数据写入队列,后台线程从队列中读取数据进行处理。当系统无法容忍因中断溢出而忽略一个或多个中断时,这提供了一种必要的替代方案。
要使前后台系统获得最佳性能,应尽可能将更多工作移至后台线程。后台线程成为队列和相关处理数据例程的集合。虽然这可以优化单个 ISR 的延迟,但转移到后台的更大工作量需要对处理器时间进行管理分配。每个后台任务的重要性不同,应分配不同比例的 CPU 时间。虽然可以通过不同频率检查队列来实现粗略的优先级系统,但此时通常需要考虑更复杂的方法。

2. 多线程编程

随着嵌入式实时应用程序的软件变得越来越复杂,需要更好的处理器时间管理策略。

2.1 多线程程序的组织

多线程程序被组织为一组独立的线程,在后台处理数据,ISR 仅负责填充输入队列和清空输出队列。将数据处理从 ISR 移至后台,通过最小化执行 ISR 的时间来减少延迟,还能使 ISR 设计为简单的直线代码,无需等待循环。以下是多线程实时嵌入式软件的组织图:

graph LR;
    A[ISR] --> B[Queue];
    C[ISR] --> D[Queue];
    E[ISR] --> F[Queue];
    G[ISR] --> H[Queue];
    B --> I[Background Thread];
    D --> I;
    F --> I;
    H --> I;
    J[Multithreaded run - time function library (the real - time kernel)] --> I;

在嵌入式系统中,每个线程通常实现为一个函数,先进行一些初始化,然后进入无限处理循环。在循环顶部,线程通常等待数据可用、外部事件发生或条件变为真。
线程调用运行时例程库(即实时内核),该库用于管理多线程程序中的资源。内核提供从一个线程切换到另一个线程的机制,以及在线程之间建立协调、同步、通信和优先级的功能。该库通常还包括管理队列的例程,甚至可能与 ISR 接口以进一步提高响应时间。

2.2 独立线程的并发执行

多线程程序中的每个线程就像拥有自己独立的处理器一样运行。大多数情况下,线程的设计、编程和行为就像它是唯一运行的线程。将后台划分为一组独立的线程极大地简化了单个线程的设计,从而降低了程序的总体复杂度。
一个重要但有时困难的设计目标是将后台软件划分为线程,以最小化线程间的联系,从而最大化线程的独立性。不过,线程有时需要共享信息或协调执行,内核以规范的方式提供这些服务,有助于良好的程序架构并降低复杂度。
同一时间只有一个线程运行,其他线程处于挂起状态。但处理器在不同线程之间切换得非常快,从外部环境看,所有线程似乎在同时运行,实际上线程是并发运行的。程序员根据线程的重要性为每个线程分配优先级,内核中的调度程序使用此信息确定下一个运行的线程。

2.3 上下文切换

每个线程都有自己的栈和一个特殊的内存区域,称为上下文。上下文包括线程栈的内存以及线程上次挂起时所有 CPU 寄存器内容的副本。
从线程 A 到线程 B 的上下文切换,首先将所有 CPU 寄存器保存在上下文 A 中,然后从上下文 B 重新加载所有 CPU 寄存器。由于 CPU 寄存器集包括栈指针(SP)和程序计数器(PC),重新加载上下文 B 实际上会重新激活线程 B 的栈,并返回到它上次挂起的位置。以下是上下文切换的示意图:

graph LR;
    A[Thread A - Executing] --> B[Save context A];
    B --> C[Thread A - Suspended];
    D[Thread B - Suspended] --> E[Restore context B];
    E --> F[Thread B - Executing];
2.4 非抢占式(协作式)多线程

在非抢占式多线程中,线程必须显式调用内核例程来执行上下文切换。通过调用此例程,线程放弃处理器的控制权,允许另一个线程运行。这种上下文切换调用通常称为 yield,这种多线程形式也称为协作式多线程。
当外部事件发生时,处理器可能正在执行并非专门处理该事件的线程,直到当前线程执行到下一个 yield 时,才有可能执行所需的线程。当 yield 实际发生时,可能会有其他几个线程先被调度运行。在大多数情况下,这使得预测非抢占式多线程系统的最坏情况响应时间变得不可能或极其困难。
显然,程序员必须频繁调用 yield 例程,否则系统响应时间会受到影响。至少,在线程等待某些外部条件的任何循环中都必须插入 yield。在其他需要长时间完成的循环(如读写文件)中,或者在冗长的计算过程中,也可能需要定期插入 yield。非抢占式系统中的上下文切换流程如下:

graph TD;
    A[Start] --> B[Data Processing Thread - Initialization];
    B --> C{Wait?};
    C -- Yes --> D[Yield to other threads];
    D --> E{Scheduler selects highest priority thread that is ready to run};
    E -- Not current thread --> F[Current thread is suspended];
    F --> G[New thread resumed];
    G --> C;
    E -- Current thread --> C;
    C -- No --> C;
2.5 抢占式多线程

在抢占式多线程中,硬件中断用于触发上下文切换。当外部事件发生时,相关的硬件 ISR 被调用。ISR 调用内核例程,通知等待数据的线程数据已可用。内核决定哪个线程最需要处理器(通常是处理数据的线程),并执行上下文切换,使 ISR 返回该线程而不是被中断的线程。由于线程的调度由外部事件的顺序驱动,系统响应时间得到显著改善。
抢占式多线程消除了程序员在各个后台线程中显式调用内核例程以让出处理器的义务。程序员无需担心 yield 例程的调用频率,只有在需要时(即响应外部事件时)才会调用。以下是抢占式系统中的上下文切换流程图:

graph TD;
    A[Thread A - Executing] --> B{Interrupt};
    B --> C[Process Interrupt Request];
    C --> D[ISR];
    D --> E[Context Switch];
    E --> F[Thread B - Suspended];
    F --> G[Thread B - Executing];
    E --> H[Thread A - Suspended];
3. 共享资源和临界区

在多线程编程中,共享资源和临界区的管理至关重要。

3.1 共享资源的问题

如果两个或多个线程同时向同一个共享的磁盘、打印机、网卡或串口发送数据,且访问未得到协调,数据可能会混合在一起,导致目的地的数据混乱。
操作共享资源的指令序列形成临界区,必须防止被操作同一资源的其他异步代码抢占。否则,相关数据和程序的正常运行可能会被破坏。一旦受到保护,临界区就成为一个原子操作,保证在没有干扰或数据损坏的情况下执行完毕。

3.2 共享内存的异步访问方式

在多线程应用中,两个或多个线程通常通过公共数据结构传递信息进行通信,这种数据结构是一种特殊的共享资源,称为共享内存。异步访问共享内存主要有以下三种方式:
1. 线程和 ISR 之间共享内存:如果线程的临界区被中断以执行 ISR,可能会发生数据损坏。
2. 两个 ISR 之间共享内存:如果一个 ISR 的临界区被中断以执行另一个 ISR,可能会发生数据损坏。
3. 两个线程之间共享内存:除非协调它们临界区的执行,否则可能会发生数据损坏。
非抢占式系统比抢占式系统在防止此类错误方面具有天然的优势,因为程序员可以明确控制上下文切换的位置和时间。程序员只需确保在操作资源的序列中间不直接或间接调用可能导致上下文切换的内核函数。
在抢占式系统中,程序员无法控制上下文切换的时间和位置,因为它们可能由中断触发。除了避免调用内核函数外,程序员还必须使用以下技术来仲裁对资源的访问。

3.3 保护共享资源的技术
  • 禁用中断 :当 ISR 访问共享资源时,唯一防止数据损坏的方法是在其他任何访问期间禁用中断。不过,在不涉及 ISR 的情况下,只要仅在极少数代码行中禁用中断,这也是一种合适的选择。需要注意的是,禁用中断的最长持续时间会增加每个任务的最坏情况响应时间,但与其他技术相比,禁用和启用中断的开销极小。
  • 禁用任务切换 :在执行长临界区时禁用中断会显著降低系统响应时间,因为这会阻止所有其他线程和 ISR 的执行,即使它们不需要访问共享资源。一种略有不同的方法是在临界区期间禁用任务切换,同时保持中断启用。大多数内核为此提供了几个非常快速的函数。禁用任务切换可以保护竞争线程中的临界区,但由于它不禁用中断,因此无法保护由 ISR 操作的共享资源。

并发软件编程全解析:从前后台系统到多线程编程

4. 前后台系统与多线程编程的对比

为了更清晰地了解前后台系统和多线程编程的特点,我们可以通过以下表格进行对比:
| 特性 | 前后台系统 | 多线程编程 |
| — | — | — |
| 响应时间 | 整体响应时间可能较差,后台循环易被低优先级中断暂停 | 可通过合理调度实现更快响应时间,尤其是抢占式多线程 |
| 编程复杂度 | 相对简单,但处理复杂任务时管理延迟和中断溢出较困难 | 设计和管理更复杂,但能更好地实现任务的独立和并发 |
| 资源管理 | 主要通过队列将数据从 ISR 传递到后台处理 | 内核提供丰富服务管理资源,包括线程同步、通信等 |
| 中断处理 | 中断处理集中在 ISR,可能导致中断溢出 | 可将部分工作移至线程,减少 ISR 负担 |

从表格中可以看出,前后台系统适用于对响应时间要求不是特别高、任务相对简单的场景;而多线程编程则更适合处理复杂任务、对响应时间有严格要求的系统。

5. 并发编程的实际应用案例

为了更好地理解并发编程的实际应用,我们来看一个简单的传感器数据处理系统的案例。

5.1 系统需求

该系统需要实时处理多个传感器的数据,并将处理结果显示在显示屏上。传感器数据包括温度、湿度和海拔高度,数据更新频率较高。

5.2 前后台系统实现
  • 前台(ISR) :每个传感器产生数据时触发相应的 ISR,ISR 将数据写入队列。
// 温度传感器 ISR
void Temperature_ISR(void)
{
    unsigned int temperature = Read_Temperature_Sensor();
    Enqueue_Data(temperature, TEMPERATURE_QUEUE);
}

// 湿度传感器 ISR
void Humidity_ISR(void)
{
    unsigned int humidity = Read_Humidity_Sensor();
    Enqueue_Data(humidity, HUMIDITY_QUEUE);
}

// 海拔高度传感器 ISR
void Altitude_ISR(void)
{
    unsigned int altitude = Read_Altitude_Sensor();
    Enqueue_Data(altitude, ALTITUDE_QUEUE);
}
  • 后台 :后台循环从队列中取出数据进行处理,并更新显示屏。
void Background_Loop(void)
{
    while (1)
    {
        if (!Is_Queue_Empty(TEMPERATURE_QUEUE))
        {
            unsigned int temperature = Dequeue_Data(TEMPERATURE_QUEUE);
            Process_Temperature(temperature);
            Update_Display(TEMPERATURE_DISPLAY, temperature);
        }
        if (!Is_Queue_Empty(HUMIDITY_QUEUE))
        {
            unsigned int humidity = Dequeue_Data(HUMIDITY_QUEUE);
            Process_Humidity(humidity);
            Update_Display(HUMIDITY_DISPLAY, humidity);
        }
        if (!Is_Queue_Empty(ALTITUDE_QUEUE))
        {
            unsigned int altitude = Dequeue_Data(ALTITUDE_QUEUE);
            Process_Altitude(altitude);
            Update_Display(ALTITUDE_DISPLAY, altitude);
        }
    }
}
5.3 多线程编程实现
  • 线程划分
    • 温度处理线程:负责处理温度传感器数据。
    • 湿度处理线程:负责处理湿度传感器数据。
    • 海拔高度处理线程:负责处理海拔高度传感器数据。
    • 显示更新线程:负责更新显示屏。
// 温度处理线程
void Temperature_Thread(void)
{
    while (1)
    {
        Wait_For_Temperature_Data();
        unsigned int temperature = Get_Temperature_Data();
        Process_Temperature(temperature);
        Notify_Display_Update(TEMPERATURE_DISPLAY, temperature);
    }
}

// 湿度处理线程
void Humidity_Thread(void)
{
    while (1)
    {
        Wait_For_Humidity_Data();
        unsigned int humidity = Get_Humidity_Data();
        Process_Humidity(humidity);
        Notify_Display_Update(HUMIDITY_DISPLAY, humidity);
    }
}

// 海拔高度处理线程
void Altitude_Thread(void)
{
    while (1)
    {
        Wait_For_Altitude_Data();
        unsigned int altitude = Get_Altitude_Data();
        Process_Altitude(altitude);
        Notify_Display_Update(ALTITUDE_DISPLAY, altitude);
    }
}

// 显示更新线程
void Display_Update_Thread(void)
{
    while (1)
    {
        Wait_For_Display_Update();
        Update_Display();
    }
}

在这个案例中,如果使用前后台系统,当传感器数据更新频率较高时,可能会出现中断溢出的问题,导致数据处理不及时;而使用多线程编程,可以将不同传感器的数据处理任务分配到不同的线程中,提高系统的并发处理能力和响应速度。

6. 并发编程的优化建议

在进行并发编程时,为了提高系统性能和稳定性,我们可以遵循以下优化建议:
1. 合理分配线程优先级 :根据任务的重要性和实时性要求,为每个线程分配合适的优先级。例如,对于传感器数据处理线程,由于需要实时响应,应分配较高的优先级;而显示更新线程可以分配较低的优先级。
2. 减少临界区代码长度 :临界区代码过长会增加数据冲突的风险,同时也会影响系统的并发性能。尽量将非关键代码移出临界区,减少线程阻塞的时间。
3. 使用高效的数据结构 :选择合适的数据结构可以提高数据的读写效率。例如,在前后台系统和多线程编程中,队列是一种常用的数据结构,用于数据的传递和缓冲。
4. 避免死锁 :死锁是并发编程中常见的问题,会导致系统陷入无限等待状态。在设计线程同步机制时,要确保不会出现循环等待的情况。

7. 总结

并发编程是现代嵌入式系统开发中不可或缺的技术,前后台系统和多线程编程是两种常见的并发编程模型。前后台系统结构简单,适用于简单任务;多线程编程则提供了更强大的并发处理能力和更灵活的资源管理方式,适用于复杂任务和对响应时间要求较高的系统。

在实际应用中,我们需要根据系统的需求和特点选择合适的并发编程模型,并遵循优化建议,以提高系统的性能和稳定性。同时,要注意处理共享资源和临界区的问题,避免数据冲突和死锁的发生。通过合理运用并发编程技术,我们可以开发出高效、稳定的嵌入式系统。

希望本文能帮助你更好地理解并发编程的原理和应用,在实际开发中取得更好的效果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值