线程安全,其实就是对共享资源的保护。如果两条或者多个资源对共享资源进行访问,但是没有上锁,就可能会发生翻车(拿到脏数据)。
竞争与协助
在单核CPU系统中,为了实现多个程序同时运行的假象,操作系统通常以时间片调度的方式,让每个进程执行每次一个时间片,时间片用完了,就切换下一个进程运行,由于这个时间片的时间很短,于是造成了并发的现象。

另外,操作系统也为每个进程创建巨大,私有的虚拟内存的假象,这种地址空间的抽象让每个进程好像都拥有自己的内存,而实际上操作系统在背后秘密地让多个地址空间复用物理内存或者磁盘。

如果一个程序只有一个执行流程,也代表它是单线流程的。当然一个程序可以有多个执行流程,也就是所谓的多线程程序。线程是调度的基本单位,进程则是资源分配的基本单位。
所以,线程之间可以共享进程的资源,比如代码段,堆空间,数据段,打开的文件等资源,但是每个线程都有自己独立的栈空间。
多线程
那么问题来了,多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。下面是两个线程,它们分别对共享变量i自增1执行10000次,如下代码所示:
按理来说,i变量最后的值应该是20000,但是很不幸,并不是如此,我们对上面的程序执行一下:
两次运行得到不同的结果,这在计算机领域里面是不能容忍的,虽然是小概率出现的错误,但是墨菲定律告诉我们:小概率事件它一定是会出现的。
为什么会发生这种情况,我们必须了解编译器为了更新计数器i变量生成的代码序列,也就是要了解汇编指令的执行顺序。
可以发现,只是单纯给i加上数字i, 在CPU运行的时候,实际上要执行3条指令。设想我们的线程1进入这个代码区域,它将i的值(假设现在是50)从内存加载到寄存器中,然后它向寄存器加1,此时寄存器中的i值是51。
现在,一件不幸的事情发生了,时钟发生中断,因此,操作系统将当前正在运行的线程的状态保存到线程的线程运行控制块TCB。
现在更糟糕的事情发生了,线程2被调度运行,并进入同一段代码。它也执行第一条指令,从内存获取i值将其放入到寄存器中。此时内存中i的值仍然为50,因此线程2寄存器中的i值也是50。假设线程2执行接下来的两条指令,将寄存器中的i值 + 1,然后将寄存器中的i值保存到内存中,于是此时全局变量i值是51。
最后,又发生了一次上下文的切换,线程1恢复执行。线程1寄存器中的i值是51,因此,执行最后一条指令后,将值保存到内存,全局变量i的值再次被设置为51。
简单来说,增加i(值为50)的代码被运行两次,按理来说,最后的i值应该是52,但是由于不可控的调度,导致最后i值却是51。

上面展示的情况被称为竞争条件(race condition), 当多线程相互竞争操作共享变量时,由于运气不好,在执行的过程中发生了上下文切换,我们得到了错误结果。事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate)。
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section), 它是访问共享资源的代码片段,一定不能给多线程同时执行。
我们希望这段代码是互斥的(mutualexclusion)的,也就是说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区。说白了,就是这段代码在执行的过程中,最多只能出现一个线程。

互斥也不是只针对多线程,在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。
同步的概念
互斥解决了并发进程/线程对临界区的使用问题,这种基于临界区控制的交互是比较简单的,只要一个进程/线程进入了临界区,其他试图想进入临界区的进程/线程都会被阻塞着,知道第一个进程/线程离开了临界区。
我们都知道在多线程里面,每个线程并不是一定是顺序执行的, 它们基本是各自独立的,不可预知的。但是有时候我们又希望多个线程能够密切合作,以实现一个共同的任务。
例如,线程1是负责读入数据的,而线程2是负责处理数据的,这两个线程是相互合作,相互依赖的。线程2在没有收到线程1的唤醒通知时,就会一直阻塞等待,当线程1读完数据需要把数据传给线程2,线程1会唤醒线程2,并把数据交给线程2处理。
所谓同步,就是并发进程/线程在一些关键点上可能需要相互等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
举个生活的同步例子。你肚子饿了想要吃饭,你叫妈妈早点做饭,妈妈听到后开始做菜,但是在妈妈没有做完饭之前,你必须阻塞等待。等妈妈做完饭后,自然会通知你,接着你吃饭的事情就可以进行了。

互斥和同步的实现和使用
在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,例如有互斥和同步的关系。
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:
- 锁:加锁,解锁操作
- 信号量:P, V操作
这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步
锁
使用加锁操作和解锁操作可以解决并发线程/进程互斥的问题
任何想进入临界区的线程,必须先执行加锁定操作。若加锁操作顺利通过,则线程可以进入临界区,在完成对临界区资源的访问后再执行解锁操作,以解放该临界区资源。
信号量
信号量是操作系统提供的一种协调共享资源访问的方法。通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。另外,还有两个原子操作的系统调用函数来控制信号量,分别是:
- P操作:将sem减1,相减后,如果sem <= 0,则进程/线程进入阻塞等待,否则继续。P操作会阻塞
- V操作:将sem加1,相加后,如果sem > 0, 唤醒一个等待中的进程/线程。V操作不会阻塞
P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。

