Skip to the content.

Synchronization1: Concurrency and Mutual Exclusion

1. Recall && Intro

1.1 Goals for Today: Synchronization

操作系统如何通过线程提供并发性?

引入同步需求

讨论锁和信号量(Locks and Semaphores

操作系统通过创建、管理和调度线程来实现并发。每个线程都有自己独立的执行路径,但在同一进程中的多个线程可以共享资源,比如内存空间。因此,通过有效地管理和调度这些线程,操作系统可以使多个任务同时进行。

进程/线程状态包括新建、就绪、运行、等待(阻塞)和完成等。调度器根据特定策略(例如轮转法、优先级调度法等)从就绪队列中选择一个进程或线程分配给CPU。

堆栈对于并发性非常重要。每个线程都有自己独立的堆栈,用于存储局部变量以及跟踪函数调用和返回地址。由于每个线程都有自己独立的堆栈,所以它们可以独立地执行不同的任务。

同步是必要的因为多个并发运行的线程可能需要共享数据或者资源。如果没有适当地控制对共享资源的访问,则可能会导致数据不一致或其他问题。

锁(Locks)和信号量(Semaphores)是两种常见且基础的同步机制。锁通常用于保护临界区域只能被一个线程在任意时刻访问;而信号量则更加通用,并可用来控制对一组资源(而非单一资源)或者协助多个进程/线程序列化相关操作。

1.2 Multiplexing Processes: The Process Control Block(多路复用进程:进程控制块)

内核使用所谓的进程控制块(Process Control Block,PCB)来表示每一个进程。这是一种数据结构,其中包含了关于进程的重要信息。

内核调度器维护一个包含所有PCB的数据结构。

分配非CPU资源如内存和IO设备也是根据某种策略决定。

1.3 Context Switch

image-20230827161021980

进程的上下文切换,本质上是通过切换PCB的内容来实现进程调度。每一次进程调度都需要两次状态切换操作,因此要控制好进程切换的频率。

在许多操作系统中,特权级别用于区分不同的运行环境,以此来保护系统的关键部分免受恶意或误操作的影响。以下是两个常见的特权级别:

还有介于0和3之间的状态,同理他们的权限也介于用户态和内核态之间,但是不怎么常用,我们常用的是0和3这两个特权级别。

1.4 Lifecycle of a Process or Thread

image-20230827161756775

随着进程的执行,它会改变状态:

注意,当一个进程处于terminated状态时,他就是一个zombie进/线程,直到它的父进程得到了该进程的执行结果后,这个僵尸进程就可以被释放了。

注意这是针对一个核的调度图,如果我们有多个核的话,会有多个这样的调度队列存在,每个核会负责一部分进程或线程的执行,但是注意一个线程不可能在同时在多个核上执行,因为他只有一个栈。

1.5 Scheduling: All About Queues

image-20230827163234697

这是内核中维护的各种队列,PCB会在这些队列之间来回移动,CPU代表的是running process,剩下的也很好理解,之后将会更深入的讨论这个话题。

1.6 Ready Queue And Various I/O Device Queues

image-20230827164037732

进程不在运行 -> 这意味着PCB在某个调度器队列中:

每个设备/信号/条件都有单独的队列:

每个队列可以有不同的调度策略:

1.7 Recall: Single and Multithreaded Processes

线程封装并发性:“活动”组件:

地址空间封装保护:“被动”部分:

为什么每个地址空间要有多个线程?这主要有以下几点原因:

总结起来说,在一般情况下,每个地址空间(即每个进程)都会有多个线程序列化相关操作主要是为了实现更好地并发性、效率和资源利用率。

1.8 The Core of Concurrency: the Dispatch Loop

从概念上讲,操作系统的调度循环如下所示:

Loop {
    RunThread();           // 运行线程
    ChooseNextThread();    // 选择下一个线程
    SaveStateOfCPU(curTCB); // 保存当前CPU状态(即当前线程控制块,TCB)
    LoadStateOfCPU(newTCB); // 加载新的CPU状态(即新的线程控制块,TCB)
}

这是一个无限循环。

我们是否应该退出这个循环?如果需要退出,那会是什么时候呢?

2. Run a thread/process

首先考虑Dispatch Loop中的第一个部分:RunThread()

如何运行一个线程?

调度程序如何重新获得控制权?

2.1 Internal Events

阻塞在I/O上:请求I/O的行为隐含地放弃了CPU。

等待其他线程的“信号”:线程请求等待其他的进程或线程,因此可以放弃CPU。

线程执行yield():线程主动放弃CPU。

2.2 Stack for Yielding Thread

image-20230828163225707

如何运行一个新线程? 我们可以通过以下函数来运行一个新线程:

run_new_thread() {
    newThread = PickNewThread();
    switch(curThread, newThread); // 下面重点说一下这个switch
    ThreadHouseKeeping(); /* Do any cleanup */
}

在这个函数中,PickNewThread()是选择新的线程来运行,switch(curThread, newThread)负责切换当前正在运行的线程到新选定的线程,ThreadHouseKeeping()则用于执行任何必要的清理工作。

调度器如何切换到一个新线程?

当调度器准备切换到一个新的线程时,它首先需要保存当前正在运行的线程可能会被下一个线程修改或覆盖的所有信息。这包括程序计数器(即下一条要执行指令的地址),寄存器(存储了CPU需要进行计算和操作所需数据)以及堆栈指针(用于跟踪程序中函数调用和变量分配)。

2.2.1 User stack : kernel stack=1 : 1

此外,在多任务环境中,每个进程或者线程都应该相互隔离。也就是说,在默认情况下,一个进程不能访问另一进程的内存空间。这样可以防止错误或恶意代码影响其他任务,并有助于保护用户数据不被未经授权访问。

当一个线程调用了yield()函数进入内核态时,它的栈也会随之改变,从用户栈(蓝色)切换到内核态(红色),每个线程在内核中都会有一个属于自己的专门的内核栈。

因此可以理解为内核栈是用来做TCB信息的保存与切换,涉及到线程/进程的切换之类的操作,或一些别的必要的操作的。

(1) 通俗易懂的了解——Linux线程模型和线程切换 - 知乎. https://zhuanlan.zhihu.com/p/138827137. (2) 操作系统进程(线程)切换内核栈存放哪些内容? - 知乎. https://www.zhihu.com/question/520012483. (3) 浅谈Linux 中的进程栈、线程栈、内核栈、中断栈 - 知乎. https://zhuanlan.zhihu.com/p/188577062.

2.3 What Do the Stacks Look Like?

举一个例子说明线程/进程之间是如何进行切换的,重点关注上面提到的在内核栈中执行的switch()调用,此外我们假设当前系统中只有一个CPU core:

假设我们有如下的两个函数:

proc A() {
	B();
}
proc B() {
    while(TRUE) {
    	yield();
    }
}

假设我们有两个线程,分别是S与T,他们执行完全一致的代码段:

image-20230828170917762

当调用switch()后,就会切换到另一个线程的栈顶,究其原因,我们来看一下switch()的伪代码:

2.3.1 Saving/Restoring state (often called “Context Switch)

Switch(tCur,tNew) {
    /* Unload old thread */
    TCB[tCur].regs.r7 = CPU.r7;
    
    TCB[tCur].regs.r0 = CPU.r0;
    TCB[tCur].regs.sp = CPU.sp;
    TCB[tCur].regs.retpc = CPU.retpc; /*return addr*/
    
    /* Load and execute new thread */
    CPU.r7 = TCB[tNew].regs.r7;
    
    CPU.r0 = TCB[tNew].regs.r0;
    CPU.sp = TCB[tNew].regs.sp;
    CPU.retpc = TCB[tNew].regs.retpc;
    return; /* Return to CPU.retpc */
}

由源代码不难看出,当Switch()函数执行完后,不是返回到原地,而是返回到CPU.retpc指向的位置,由于我们已经加载了新的进程/线程的PCB,因此会返回到下一个进程中待执行的指令,我们假设此时是由S切换到了T;

T开始执行,执行yield(),切换到内核堆栈,执行Switch(),Switch完成线程切换,这时就会从T切换到S,而S此时就可以完成run_new_thread(),之后就是S线程的栈函数一路弹出,之后再次进入yield()函数,重复上面的逻辑。

因此表现出来就是S与T线程之间的反复切换。

2.4 Switch Details (continued)

线程控制块(TCB)和堆栈(用户态/内核态)包含了线程完整的可重启状态!

如果在实现切换时犯了错误会怎样?

你能设计一个详尽无遗的测试来测试switch()函数的代码吗?

谨慎地讲述一个故事:

2.5 Aren’t we still switching contexts?(用户线程与内核线程的对应关系)

我们不是仍在进行上下文切换吗?

来自Linux的一些数据:

image-20230828194614086

甚至更便宜:在用户空间使用“yield”函数强制线程放弃CPU控制权,以便其他线程可以运行!我们在用户态调用用户级别的yield()函数,用户代码库知道如何进行做同栈切换,他在线程切换之间保存并恢复寄存器的状态,但是不需要进入内核态。

1对1的模型优点是每个用户线程都有一个对应的内核级线程,当一个线程因为I/O操作被放入wait队列时,这意味着它的内核线程被放入了等待队列,但是别的线程依然可以继续运行。

多对一线程虽然在拥有同一个内核栈的线程之间进行切换的效率更高,不必进入内核态,但是一旦有一个线程因为I/O操作阻塞,对应的内核级线程被放入wait队列,那么该内核级线程对应的所有用户级线程都不能被运行。

因此折中一下,就出现了多对多线程模型:少量的内核级线程对应了更多的用户级线程。今天我们只会讨论

2.6 Processes vs. Threads

1. one core

image-20230828195732003

切换开销:同一进程内进行线程切换开销低,不同的就高。

保护:同一进程内的线程保护程度较低,不同的就高。

共享开销:同一进程内的共享开销低,不同的高

并行性:单核没有并行性。

2. Multi-core

上层与上图相似,只不过最底层的CPU core从1个变成了多个。

切换开销:同一进程内进行线程切换开销低,不同的就高。

保护:同一进程内的线程保护程度较低,不同的就高。

共享开销:同一进程内的共享开销低;不同的进程,且目标进程目前都在CPU core上运行,那么共享的开销是中等;不同的进程,且有一些进程没有在CPU core上运行,那么共享的开销就很高。

并行性:多个cpu cores当然具有并行性。

2.7 Simultaneous MultiThreading/Hyperthreading

image-20230828201555591

黄色与棕色意味着正在执行指令,灰色代表空闲。

硬件调度技术

可以调度每个线程,就好像它是单独的CPU

原始技术称为“同时多线程”

超线程技术和超标量架构是两种不同的提高CPU性能的技术,实际上它们可以在同一处理器中同时使用。它们各自具有自己的优势,并解决了CPU利用率的不同方面。

超标量架构是一种在处理器中用于在一个时钟周期内执行多于一条指令的方法。这是通过同时将多个指令分派到处理器的不同部分来实现的。对于可以并行或独立执行指令的任务,超标量处理器可以大大提高性能。

另一方面,超线程(也称为同时多线程)是一种允许在单个核心上执行多个线程的技术,有效地使操作系统将一个物理核心视为两个虚拟核心。超线程的目标是增加每个CPU核心内计算资源的利用率,并更好地支持多任务处理。

就资源利用率而言,当某些线程等待I/O操作或内存访问时,超线程可以帮助保持CPU忙碌,因为其他线程可以同时执行。然而,与当有许多可立即执行的独立指令时像超标量执行那样增加原始计算吞吐量并不完全相同。

所以说,比起超标量架构,是否会因为使用了超线程技术而使得 CPU 被更充分地利用起来其实真正取决于运行在计算机上特定工作负载类型。对于有很多独立指令需要被运算处理的任务来说, 超标量设计可能会带来显著效益; 对于需要频繁等待(例如很多服务器工作负载) 的任务场景, 则最好使用超线程技术得到整体更好地利用效果。

3. External Events

3.1 What happens when thread blocks on I/O?

image-20230830144524136

当一个线程需要从文件系统中读取一块数据时,首先由用户空间的代码发起一个系统调用。这个系统调用会被操作系统内核捕获,并开始执行相应的文件读取操作。

读取操作启动后,数据从磁盘(或其他存储设备)被加载到内存中。这个过程可能需要一些时间,因为它涉及到I/O操作,这通常比CPU和内存之间的交互要慢得多。

在等待数据加载的同时,操作系统可能会选择运行其他的线程或者进行上下文切换。这样可以更充分地利用CPU资源,而不是让它在等待I/O操作完成时闲置。

对于线程之间的通信也有类似的过程。例如,在某些情况下,一个线程可能需要等待另一个线程发送一个信号或者完成某项任务(通过join机制)。在网络编程中,也经常需要进行类似的等待——例如,在发送请求并等待服务器响应时。

可知,上述线程包含了一个用户站和一个内核栈,switch操作负责保存当前线程的执行状态,之后切换到另一个线程,返回当前线程的用户态程序。

3.2 External Events

如果线程从不进行任何I/O操作,从不waits,也从不yields控制权会发生什么?

答案:利用外部事件(External Events

如果我们确保外部事件频繁地发生,就可以确保调度器运行。

在多任务操作系统中,如果一个线程从未进行任何I/O操作、等待(waits其他线程)或主动放弃CPU(例如通过yield),那么理论上这个线程可以一直占用CPU而不被打断。这可能会导致其他线程无法得到执行机会。

为了防止这种情况发生,现代操作系统通常采用抢先式多任务调度策略。在这种策略下,即使一个线程想要无限制地使用CPU也做不到。因为操作系统有一个叫做“计时器”的机制——每当计时器达到预设的时间间隔(比如几十毫秒)后, 它就会产生一个中断信号。

当中断信号产生后, CPU 就会立刻停止当前正在执行的任务, 转而去处理这个中断: 它将保存当前任务的状态, 然后跳转至内核特定的中断处理程序去执行相应操作——在此例中则是进行任务切换。

所以说只要我们确保”外部事件”(比如由计时器产生的中断)足够频繁地发生, 那么我们就能确保调度器有机会去运行并对 CPU 进行合理分配。

3.3 Interrupt Controller

image-20230830145641155

在计算机系统里,硬件设备通过向CPU发送信号来触发”硬件中断”。这些信号是通过所谓的”中断线”(Interrupt Lines)传递的。

当有多个设备同时发送了信号时,就需要有一个叫做“中断控制器”(Interrupt Controller)的硬件来决定哪个请求应该被优先处理。它会根据每个请求所带有的“ID”和其对应“优先级”来进行判断。

“掩码”是一种可以让我们手动开启或关闭某些特定类型或者特定设备产生的 中断 的工具。

对于那些我们希望在任何情况下都不被打扰的重要任务, 我们甚至可以让 CPU 直接忽略所有类型的 中断 请求。但唯一例外就是那些经过 “不可屏蔽位” (Non-Maskable Interrupt, NMI) 发起 的请求——正如其名字所表达出来那样, 这类请求无论在什么情况下都不能被忽视或者说是阻止。

另外还存在一种叫做 “软件触发式 中断” (Software Interrupt) 的机制: 它并非由硬件设备发起, 而是直接由运行在 CPU 上面代码所主动调用——例如我们可能会使用这种方式来手动触发一个系统调用操作。

3.4 Example: Network Interrupt

image-20230830150035539

在计算机系统中,当硬件设备(如键盘、鼠标、网络卡等)需要CPU注意时,它们会通过发送一个信号来触发一个”硬件中断”。

当CPU收到这个信号后,它会立刻停止当前正在执行的任务,并保存其状态(这就是所谓的”上下文切换”),然后跳转到预先设定好的特定位置去执行对应的 “中断处理程序”(Interrupt Handler)。

这个过程并不需要我们人工干预或者说进行任何额外操作——比如说选择接下来要运行哪个任务——因为所有相关操作都已经在系统初始化或者设备驱动加载时被自动设置好了。所以我们可以说,一旦一个中断请求被触发, 那么相应的中断处理程序就会被立即执行。

3.5 Use of Timer Interrupt to Return Control

image-20230830152436770

在接收到这个信号后,操作系统会执行一段名为 “TimerInterrupt()” 的特殊函数(也称为“中断服务例程”)。在这个函数里,我们可以首先完成一些必要的维护工作(如更新系统时间、检查硬件状态等),然后再根据当前所有待处理任务的状态和优先级选择下一个应该被执行的线程,并将CPU控制权交给它。

这样做有两个主要好处:首先,通过固定间隔地切换任务,我们可以确保每个线程都能得到公平地运行机会;其次,在每次切换前都重新评估并选择最合适的线程来运行也使得我们能更灵活有效地响应各种动态变化情况——比如突然有新高优先级任务加入等。

3.6 How do we initialize TCB and Stack?

image-20230830154108487

初始化TCB的寄存器字段

初始化栈数据?

在多线程编程中,每个线程都有自己独立的执行环境,包括一块私有的内存空间(即”栈”),以及一个用于记录其运行状态的 “线程控制块” (Thread Control Block, TCB)。其中,堆栈用于保存函数调用过程中产生的局部变量、返回地址等信息;而 TCB 则主要保存了一些更底层或者说是全局性质的信息——比如 CPU 寄存器值、线程优先级、状态等。

在创建一个新线程时,我们首先需要初始化其 TCB 和 堆栈。对于 TCB 来说, 我们需要设置其各个字段到合适初始值——例如让 “堆栈指针” (Stack Pointer) 指向新分配给这个线程使用 的内存区域顶部;将 “程序计数器” (Program Counter) 设置为 ThreadRoot() 函数入口点;并且还可能需要设置其他各种参数。

对于堆栈来说,在函数调用过程中会动态地被修改和更新, 所以在初始化时我们通常不会去预设任何特定数据。相反,在大多数情况下,我们只需确保它有足够空间可以容纳接下来可能产生的数据即可。

这里提到“重要部分”的“ra”应该是指“返回地址”(Return Address),这是一个非常关键的概念:当一个函数被调用时,CPU 需要记住当前正在执行代码 的位置 —— 也就是 下一条将被执行的指令地址——这样当函数执行完毕后,CPU 才能知道应该跳回到哪里去继续执行。在大多数系统中,这个 “返回地址” 通常会被保存在一个特殊的寄存器或者堆栈里。

3.7 How does Thread get started?

image-20230830154213371

在操作系统中,创建一个新线程需要进行一系列步骤。首先,我们需要设置一个新的线程控制块(TCB)或内核线程,让它指向一个新分配的用户栈空间和ThreadRoot代码。ThreadRoot是每个新线程默认会执行的函数。

接下来,我们需要将要执行函数及其参数的指针放入寄存器中。这里要注意,如何设置这些值以及使用哪些寄存器会取决于具体使用的硬件架构和调用约定。例如,在RISC-V和x86两种架构中就可能有不同。

一旦所有这些都设置好后, 我们就可以把这个新创建 的 TCB 加入到系统待运行队列里去了。

最终,run_new_thread()将选择这个TCB并返回到ThreadRoot()的开始

在多线程编程中,一个新的线程从创建到最终运行,通常需要经过以下几个步骤:首先,我们需要分配和初始化一块新的内存空间(即”堆栈”)和一个 “线程控制块” (TCB);然后将这个新创建的 TCB 加入到系统的调度队列中。

当操作系统决定要运行一个新线程时(例如在处理完一个定时器中断后),它会通过调度算法选择下一个应该被执行的 TCB (比如说 run_new_thread() 函数就可能是完成这一任务)。然后操作系统会设置好所有必要环境(比如寄存器值、程序计数器等),并将CPU控制权交给这个新选出来 的 TCB 所代表 的 线程。

在这个过程中,程序计数器被设置为 ThreadRoot() 函数入口点地址。因此当 CPU 开始执行新代码时, 它实际上是在执行 ThreadRoot() 函数——也就是说, 新线程从 ThreadRoot() 开始运行。ThreadRoot() 通常会包含或者调用实际想要在线程里执行 的 用户函数。

3.8 What does ThreadRoot() look like?

ThreadRoot()是线程例程的根:

ThreadRoot(fcnPTR,fcnArgPtr) {
    DoStartupHousekeeping();
    UserModeSwitch(); /* enter user mode */
    Call fcnPtr(fcnArgPtr);
    ThreadFinish();
}

启动时的维护工作

栈将随着线程执行而增长和缩小

线程最后返回到ThreadRoot(),该函数会调用ThreadFinish()

ThreadRoot()函数通常被设计为每个新创建的线程默认会执行的第一个函数。它通常需要完成一些初始化和启动前准备工作,然后再转去运行实际想要在线程里执行的用户函数。

在这个例子中,ThreadRoot()首先完成了一些”启动时维护”工作——例如记录下这个新线程开始运行的时间以及可能还有其他一些统计信息等。

接下来,它切换到了”用户模式”。在操作系统中,通常会有两种不同级别的运行模式:内核模式和用户模式。内核模式具有完全访问硬件资源和所有内存区域的权限,而用户模式则只能访问限定范围内的资源和内存。新创建的线程通常会在用户模式下运行。

完成这些准备工作后,ThreadRoot()就可以调用fcnPtr函数了——这就是我们实际想要在线程里去完成的工作。

当fcnPtr函数执行完毕并返回后,控制权又回到了ThreadRoot().此时它会调用另一个特殊函数:ThreadFinish(),来完成一些清理工作并标记这个线程已经结束.

在多任务环境中,当一个任务(或者说“线程”)结束时,可能需要唤醒其他一些因为等待它而被挂起(或者说“睡眠”)的任务。所以在这里我们看到:ThreadFinish()还负责唤醒那些正在睡眠的线程。

4. Concurrent Examples-ATM Bank Server

image-20230830161317157

4.1 Simple Solution

BankServer() {
    while (TRUE) {
        ReceiveRequest(&op, &acctId, &amount);
        ProcessRequest(op, acctId, amount);
    }
}
ProcessRequest(op, acctId, amount) {
    if (op == deposit) Deposit(acctId, amount);
    else if …
}
Deposit(acctId, amount) {
    acct = GetAccount(acctId); /* may use disk I/O */
    acct->balance += amount;
    StoreAccount(acct); /* Involves disk I/O */
}

我们如何加速这个过程?

在上述的银行服务器例子中,我们可以看到每次只能处理一个ATM的请求。这意味着如果有很多并发的ATM操作,那么其他所有人都需要等待当前操作完成后才能进行他们的操作。

为了提高系统性能,我们可以引入并发编程技术。例如:我们可以让服务器同时处理多个客户端发送过来 的 请求——也就是说,在一个客户端的操作还在进行时(比如正在等待硬盘IO),服务器就已经开始去处理下一个客户端 的 请求了。

还有一种方法通常被称为 “事件驱动” 或者 “异步IO”:即当某个IO操作需要等待时(比如读写硬盘),不是让整个程序都停下来等待它完成,而是将其挂起,并立即开始去做其他事情。然后当那个IO操作真正完成后再通过某种机制(比如回调函数或者事件)通知程序继续执行它。

另一种提高性能的方法是 使用 “多线程” 技术:即 在 同一时间内让多个线程并行地运行。每个线程都有自己独立 的执行环境和堆栈空间,所以它们可以分别执行不同任务,并且相互之间几乎没有影响。这样既可以利用多核CPU的优势提高总体计算性能,也可以通过让不同线程处理不同任务来提高系统的响应速度。

4.2 Event Driven Version of ATM server(编程难度高)

假设我们只有一个CPU

BankServer() {
    while(TRUE) {
        event = WaitForNextEvent();
        if (event == ATMRequest)
        	StartOnRequest();
        else if (event == AcctAvail)
        	ContinueRequest();
        else if (event == AcctStored)
        	FinishRequest();
    }
}

在这个事件驱动版本的ATM服务器示例中,主要思想是通过等待和响应不同类型的事件来实现服务。例如:当收到一个新的ATM请求时,它就开始处理这个新请求;当得知某个账户已经可用时,它就可以继续之前暂停的请求;最后当账户信息被成功存储后,它就可以标记当前操作已经完成,并准备接收下一个操作。

这种模式虽然可以有效地提高单核CPU使用效率(因为在等待IO时还能去做其他事情),但也有一些潜在问题:比如说如果在编写程序时忘记了某些可能导致阻塞的操作, 那么整个程序可能会卡住而无法正常运行。

另外,在复杂系统中可能需要处理很多不同类型的事件,并且每种类型的事件可能都需要对应不同的处理函数。如果每个函数都只能做一小部分工作(以避免长时间阻塞),那么整个程序可能需要被切割成很多小片段——这无疑会大大增加编程难度和出错风险。

尽管如此,由于其优秀的响应性和资源利用率,这种技术仍然广泛应用于许多领域——尤其是需要实时交互或者对延迟敏感的场合。比如说图形界面编程、网络编程、游戏开发等等。

4.3 Can Threads Make This Easier?

使用多线程可以让编码变得更简单。你可以让每个请求都在自己的线程中运行,并且允许它们根据需要阻塞。这样就不需要将你的代码分割成非阻塞片段或者处理复杂的事件循环了。

然而,多线程也带来了新的问题:当多个线程同时访问和修改同一份数据(也就是所谓的”共享状态”)时,可能会导致数据被错误地修改或者破坏。在上述例子中,两个并发执行的存款操作可能会导致账户余额计算错误。

为了避免这种问题,我们通常需要引入某种形式的”同步”或者”互斥”机制来确保每次只有一个线程能够访问和修改那些共享数据。例如:在上述例子中我们可能需要使用一个锁来保护对账户余额的修改操作——即在开始修改前先尝试获取锁,并且只有成功获取到锁后才真正去执行修改操作;然后在完成修改后再释放那把锁。这样就可以确保即使有很多并发执行的存款操作,但任何时候都只有一个操作能够真正去改变账户余额。

所以我们需要让某些操作或某些操作的组合变成Atomic Operation

4.4 Atomic Operations

对于大部分计算机系统来说,单个内存单元的读取和写入操作都是原子的。也就是说如果一个线程正在写入某个内存位置,那么其他所有尝试读取或者写入那个位置的线程都必须等待它完成才能继续。

这只意味着Load和Store这两个汇编指令是原子的(每次针对一个内存单元)。

然而,并非所有类型的操作都是原子性。比如说对于双精度浮点数或者大数组等大型数据结构的读写就可能不是原子性。这意味着如果有两个线程同时尝试去写入同一个双精度浮点数或者复制同一个数组,那么结果可能会出现混乱——因为两次操作可能会交错进行,导致最后得到了一些既非完全由第一次操作产生、也非完全由第二次操作产生、而是由两者混合产生的”脏数据”。

因此,在编写并发程序时,我们需要非常清楚地了解哪些操作是原子的,哪些不是。对于那些非原子操作,我们需要采取额外的措施来确保数据的完整性和正确性。例如,我们可以使用锁或者其他同步机制来保证在任何时候都只有一个线程能够访问和修改那些数据。

例如,在多线程环境中处理双精度浮点数或复制大型数组时,可能需要引入锁。当一个线程开始这样的操作时,它会首先获取锁,然后执行操作,最后释放锁。在这个过程中,任何其他试图执行相同操作的线程都必须等待直到第一个线程完成并释放了锁。

这种方法可以防止数据冲突和不一致性问题,并确保你的程序在并发环境下能够正确地运行。然而,请注意使用锁也有其代价:它会增加程序复杂性,并可能导致性能下降(因为现在一次只能有一个线程执行某些操作)。因此,在设计并发程序时,应该尽量减少共享数据和必须进行同步的情况。

总结起来说,在理解并编写并发程序时,知道哪些是原子操作是至关重要的。同时还需要理解如何通过使用同步机制(如互斥量、信号量等)来处理非原子操作带来的挑战。

下面就是关于线程之间并发的案例分析。

5. Conclusion

通过复用CPU时间来实现并发:

TCB + 堆栈(stack)保存线程的完整状态以便重新启动

原子操作(Atomic Operation):一个总是运行到完成或者根本不运行的操作

同步(Synchronization):使用原子操作确保线程之间的协作

互斥(Mutual Exclusion):确保一次只有一个线程执行特定任务

关键区段(Critical Section):一次只有一个线程会进入这部分代码并执行。

锁(Locks):用于在关键区段上强制实施互斥以构造原子操作的同步机制

信号量(Semaphores):用于强制实施资源约束的同步机制

以上都是处理并发编程中常见问题和概念。理解这些基础知识对于设计和实现正确、高效且可靠 的 并发程序至关重要。同时,请记住并发编程通常比单线程编程更复杂,并且需要更细致地考虑各种可能出现问题的场景。