并发-多线程
2023-1-13
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property
 
 
一般,一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行点(多个程序计数器,每个都用于取指令和执行)。换一个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。
单个线程的状态与进程状态非常类似。线程有一个程序计数器(PC),记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换到另一个线程(T2)时,必定发生上下文切换(context switch)。线程之间的上下文切换类似于进程间的上下文切换。对于进程,我们将状态保存到进程控制块(Process Control Block,PCB)。现在,我们需要一个或多个线程控制块(Thread Control Block,TCB),保存每个线程的状态。但是,与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。
线程和进程之间的另一个主要区别在于栈。在简单的传统进程地址空间模型 [单线程(single-threaded)进程] 中,只有一个栈,通常位于地址空间的底部
notion image
然而,在多线程的进程中,每个线程独立运行,当然可以调用各种例程来完成正在执行的任何工作。不是地址空间中只有一个栈,而是每个线程都有一个栈。假设有一个多线程的进程,它有两个线程,结果地址空间看起来不同。 所有位于栈上的变量、参数、返回值和其他放在栈上的东西,将被放置在有时称为线程本地(thread-local)存储的地方,即相关线程的栈。
多个栈也破坏了地址空间布局的美感。以前,堆和栈可以互不影响地增长,直到空间耗尽。多个栈就没有这么简单了。幸运的是,通常栈不会很大(除了大量使用递归的程序)。
 

线程创建

线程创建API:
 
线程完成API:
如果想等待线程完成,必须调用函数pthread_join()
 
 
 
创建两个线程,每个线程都做了一些独立的工作,打印“A”或“B”:
notion image
这种排序不是唯一可能的顺序。给定一系列指令,有很多可能的顺序,这取决于调度程序决定在给定时刻运行哪个线程。
 

共享数据

向共享变量计数器加一个数字,并在循环中执行1000 万次,预期的最终结果是:20000000。
 
 
notion image
遗憾的是,即使是在单处理器上运行这段代码,也不一定能获得预期结果,为什么会发生这种情况?
 

不可控的调度

想给counter加上一个数字1,做这件事的代码序列可能看起来像这样(在x86 中):
假定变量counter位于地址0x8049a1c。先用x86的mov指令,从内存地址处取出值,放入eax。然后,给eax寄存器的值加1(0x1)。最后,eax的值被存回内存中相同的地址。
 
设想线程1进入这个代码区域,并且因此将要增加一个计数器。它将counter 的值(假设这时是50)加载到它的寄存器eax 中。因此,线程1 的eax = 50。然后它向寄存器加1,因此eax = 51。现在,一件不幸的事情发生了:时钟中断发生。因此,操作系统将当前正在运行的线程(它的程序计数器、寄存器,包括eax 等)的状态保存到线程的TCB。
现在更糟的事发生了:线程2被选中运行,并进入同一段代码。它也执行了第一条指令,获取计数器的值并将其放入其eax 中 [运行时每个线程都有自己的专用寄存器。上下文切换代码将寄存器虚拟化(virtualized),保存并恢复它们的值]。此时counter 的值仍为50,因此线程2 的eax = 50。假设线程2 执行接下来的两条指令,将eax 递增1(因此eax= 51),然后将eax 的内容保存到counter(地址0x8049a1c)中。因此,全局变量counter 现在的值是51。
最后,又发生一次上下文切换,线程1 恢复运行。还记得它已经执行过mov 和add 指令,现在准备执行最后一条mov 指令。回忆一下,eax=51。因此,最后的mov 指令执行,将值保存到内存,counter 再次被设置为51。
简单来说,发生的情况是:增加counter 的代码被执行两次,初始值为50,但是结果为51。这个程序的“正确”版本应该导致变量counter 等于52。
notion image
这里的情况称为竞态条件(race condition):结果取决于代码的时间执行。由于运气不好(即在执行过程中发生的上下文切换),得到了错误的结果。事实上,可能每次都会得到不同的结果。 由于执行这段代码的多个线程可能导致竞争状态,因此我们将此段代码称为临界区(critical section)。临界区是访问共享变量(或更一般地说,共享资源)的代码片段,一定不能由多个线程同时执行。
我们真正想要的代码就是所谓的互斥(mutual exclusion)。这个属性保证了如果一个线程在临界区内执行,其他线程将被阻止进入临界区。
  • 临界区(critical section)是访问共享资源的一段代码,资源通常是一个变量或数据结构。
  • 竞态条件(race condition)出现在多个执行线程大致同时进入临界区时,它们都试图更新共享的数据结构,导致了令人惊讶的(也许是不希望的)结果。
  • 不确定性(indeterminate)程序由一个或多个竞态条件组成,程序的输出因运行而异,具体取决于哪些线程在何时运行。这导致结果不是确定的(deterministic),而我们通常期望计算机系统给出确定的结果。
  • 为了避免这些问题,线程应该使用某种互斥(mutual exclusion)原语。这样做可以保证只有一个线程进入临界区,从而避免出现竞态,并产生确定的程序输出。
 

原子性愿望

解决这个问题的一种途径是拥有更强大的指令,单步就能完成要做的事,从而消除不合时宜的中断的可能性。比如,如果有这样一条超级指令:
假设这条指令将一个值添加到内存位置,并且硬件保证它以原子方式(atomically)执行。当指令执行时,它会像期望那样执行更新。它不能在指令中间中断,因为这正是我们从硬件获得的保证:发生中断时,指令根本没有运行,或者运行完成,没有中间状态。
原子方式的意思是“作为一个单元”,希望以原子方式执行3 个指令的序列:
如果有一条指令来做到这一点,可以发出这条指令然后完事。但在一般情况下,不会有这样的指令。
因此,我们要做的是要求硬件提供一些有用的指令,可以在这些指令上构建一个通用的集合,即所谓的同步原语(synchronization primitive)。通过使用这些硬件同步原语,加上操作系统的一些帮助,构建多线程代码,以同步和受控的方式访问临界区,从而可靠地产生正确的结果。
 
  • 计算机基础
  • 操作系统
  • 内存虚拟化-超越物理内存并发-锁
    目录