锁,从概念上可以分为悲观锁与乐观锁两大类,悲观锁譬如互斥量(Mutex),可重入锁,读写锁等等。而乐观锁典型的有 CAS 与自旋锁、MVCC 等。

锁必须是原子性操作实现,决不能中途打断,由处理器原语支持。锁的意义在于将操作做为一个执行单元以一种原子方式执行而不被打断,多线程下也不会互相干扰。但是锁会影响性能,这是因为一个加锁的临界资源 在被访问前必须获取对应的锁,获取该锁的线程将以独占的方式访问临界区。如果此时有其他线程同时访问临界区,则会因为无法获取这个锁而阻塞,显然,在临界区强行通过加锁使线程执行串行化是需要牺牲一定的性能的。

互斥量与临界区的作用非常相似,但互斥量是可以命名的,也就是说它可以跨越进程使用。所以创建互斥量需要的资源更多,所以如果只为了在进程内部是用的话使用临界区会带来速度上的优势并能够减少资源占用量。因为互斥量是跨进程的互斥量一旦被创建,就可以通过名字打开它。

采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限,因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享。互斥量比临界区复杂。因为使用互斥不仅仅能够在同一应用程序不同线程中实现资源的安全共享,而且可以在不同应用程序的线程之间实现对资源的安全共享。

如果共享和可变都无法避免,那么只有使用互斥/同步机制,来保证线程安全性。互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性;但互斥无法限制访问者对资源的访问顺序,即访问是无序的。同步是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的;少数情况是指可以允许多个访问者同时访问资源。

if(!occipied){ // 检查
    occupied = true; // 占锁
    critical_region(); // 临界区
    occupied = false; // 释放锁
}

最朴素的互斥手段,就是进入临界区之前,用 if 语句检查 bool 值,条件不满足就等待或者抛出异常;该值即为锁变量,不过该变量可能非线程安全,因为该动作不具备原子性。所谓的 TSL 指令,即是原子性地完成“检查-占锁”这个动作的指令。所谓的互斥量即是使用了 sleep 与 wakeup 原语,保证同一时刻只有一个线程进入临界区代码片段的锁。而将互斥锁推广到 N 维空间,同时允许有 N 个线程进入临界区的锁称为信号量。互斥量和信号量的实现都依赖于 TSL 指令保证“检查-占锁”动作的原子性。

管程即是从编译器的层面保证了临界区的互斥,譬如在 Java 代码中,通常使用 synchronized 关键字,对类或者对象加锁,来实现同步。被 synchronized 修饰的代码块及方法,在同一时间,只能被单个线程访问。synchronized 关键字以退化到单线程的方法,解决并发安全性的问题。

不使用 TSL 指令的另一种锁的方式称为自旋锁,但是自旋锁的缺点就是条件不满足时候会忙等待,需要后台调度器重新分配时间片,效率比较低。自旋锁的关键就是使用 while 轮询,代替 if 检查状态;这样就算线程切出去,另一个线程也会因为条件不满足循环忙等,不会进入临界区,我们常见的 wait()notifyAll() 等条件变量都是基于此。

// 线程 A
while(true){
    while(turn != 0){} // 锁被占用,循环忙等
    critical_region();
    turn = 1; // 释放锁
    noncritical_region();
}

// 线程 B
while(true){
    while(turn != 1){} // 锁被占,循环忙等
    critical_region();
    turn = 0; // 释放锁
    noncritical_region();
}

理解了 Semaphore,再看 Mutex 就很简单了。可以把 Mutex 理解成 count==1 的 Semaphore。在使用 Mutex 的场景下,永远都只允许有一个线程在占有资源,其它的线程都必须等待。

简单的说,互斥锁保护了一个临界区,在这个临界区中,一次最多只能进入一个线程。如果有多个进程在同一个临界区内活动,就有可能产生竞态条件(race condition)导致错误。

读写锁从广义的逻辑上讲,也可以认为是一种共享版的互斥锁。如果对一个临界区大部分是读操作而只有少量的写操作,读写锁在一定程度上能够降低线程互斥产生的代价。

条 件变量允许线程以一种无竞争的方式等待某个条件的发生。当该条件没有发生时,线程会一直处于休眠状态。当被其它线程通知条件已经发生时,线程才会被唤醒从 而继续向下执行。条件变量是比较底层的同步原语,直接使用的情况不多,往往用于实现高层之间的线程同步。使用条件变量的一个经典的例子就是线程池(Thread Pool)了。 当我们在谈论常见的锁名词时,最基础的即是信号量(Semaphore)与互斥锁(Mutex)。互斥锁的作用就是互斥,用来保护临界区(critical section)的。所谓临界区就是代码的一个区间,如果两个线程同时执行就有可能出问题,所以需要互斥锁来保护。信号量(Semaphore)是一种更高级的同步机制,Mutex 可以说是 Semaphore 在仅取值 0/1 时的特例。Semaphore 可以有更多的取值空间,可以为非负整数,用来实现更加复杂的同步,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问。自旋锁是一种互斥锁的实现方式而已,相比一般的互斥锁会在等待期间放弃 cpu,自旋锁(SpinLock)则是不断循环并测试锁的状态,这样就一直占着 CPU。

保证在某一时刻只有一个线程能访问数据的简便办法。在任意时刻只允许一个线程对共享资源进行访问。如果有多个线程试图同时访问临界区,那么在有一个线程进 入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操作共 享资源的目的。

在使用临界区时,一般不允许其运行时间过长,只要进入临界区的线程还没有离开,其他所有试图进入此临界区的线程都会被挂起而进入到等待状态,并会在一定程 度上影响。程序的运行性能。尤其需要注意的是不要将等待用户输入或是其他一些外界干预的操作包含到临界区。如果进入了临界区却一直没有释放,同样也会引起 其他线程的长时间等待。虽然临界区同步速度很快,但却只能用来同步本进程内的线程,而不可用来同步多个进程中的线程。

自旋锁对信号量

需求 建议的加锁方法

低开销加锁 优先使用自旋锁 短期锁定 优先使用自旋锁 长期加锁 优先使用信号量 中断上下文中加锁 使用自旋锁 持有锁是需要睡眠、调度 使用信号量