Skip to content

Latest commit

 

History

History
118 lines (87 loc) · 11.5 KB

线程、进程和锁.md

File metadata and controls

118 lines (87 loc) · 11.5 KB

线程、进程和锁

线程是操作系统能够调度和执行的基本单位,在Linux中也被称之为轻量级进程。从定义中可以看出,线程是操作系统的概念,在不同的操作系统中的实现是不同的。

在Linux内核中,其实是没有线程的概念的,它把所有的线程当做标准的进程来实现,也就是说Linux内核,并没有为线程提供任何特殊的调度语义,也没有为线程实现特定的数据结构。取而代之的是,线程只是一个与其他进程共享某些资源的进程。每一个线程拥有一个唯一的task_struct结构,Linux内核它仅仅把线程当做一个正常的进程,或者说是轻量级进程。

资源共享

线程与进程共享的资源有:
  • 内存地址空间
  • 进程基础信息
  • 大部分数据
  • 打开的文件
  • 信号处理
  • 当前工作目录
  • 用户和用户组属性
  • ...
线程独有的资源有:
  • 线程ID
  • 一系列的寄存器
  • 栈的局部变量和返回地址
  • 错误码 errno
  • 信号掩码
  • 优先级
  • ...

线程创建

上面也提到,在Linux中线程是一种资源共享的方式,可以在创建进程的时候,指定某些资源是从其他进程共享的,从而在概念上创建了一个线程。 在Linux中,可以通过clone系统调用来创建一个进程,它的函数签名如下:

#include <sched.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...);

我们在使用clone创建进程的过程中,可以指明相应的参数,来决定共享某些资源,比如:

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

这个clone系统调用的行为类似于fork,不过新创建出来的进程,它的内存地址、文件系统资源、打开的文件描述符和信号处理器,都是共享父进程的。

锁是计算机对资源进行并发访问控制的一种机制,多线程情况下来实现对临界资源的同步互斥访问。计算机世界里各种锁的概念比较多,对一些锁的含义经常会弄不清楚或者产生混淆。本文对常见的一些锁概念进行比较、总结,尽量给出一些通俗易懂的解释,作为入门和备忘。

操作系统层面

所有高级语言锁的实现都依赖操作系统底层的锁实现,操作系统层面上锁的实现机制有自旋锁(spinlock)和mutex两种。

1. 自旋锁

自旋锁是一种非阻塞锁,多个线程尝试获取自旋锁时,没有获取到的线程会持续尝试获取直到获取到为止。获取锁的操作大多使用CAS、TAS等硬件指令实现,比如Test and test-and-set模式自旋锁的实现:

boolean locked := false procedure EnterCritical() { do { while (locked == true) } while TestAndSet(locked) } 当然,实际实现中会做更多的tradeoff,更高效的实现需要考虑多核CPU的缓存一致性、锁变量更新时的总线开销、锁的公平性等问题。 自旋锁不涉及到线程上下文切换,相对于mutex来说比较轻量,常用作高级语言的锁优化。但是因为没有获取到锁的线程会进入”忙等 “状态消耗CPU资源,如果获取到锁的线程持有锁的时间比较长,会对性能造成一定影响。所以,自旋锁一般适用于线程持有锁的时间比较短,锁的获取和释放频繁的情况。

2. mutex阻塞锁

mutex是阻塞锁,多个线程尝试获取锁时,没有获取到锁的线程会被操作系统调度为阻塞状态直到锁被释放然后才会被重新唤醒。OS线程调度,线程上下文切换带来的开销是很大的,多线程程序如果有大量的线程切换,最坏情况下性能甚至会比单线程运行的代码效率还要差。mutex锁如果被频繁的获取和释放,代价也可想而知。mutex的实现和开销分析可以参见:How does a mutex work? What does it cost?

spinlock比较高效,适用于线程持有锁时间短的情况;mutex适用于线程持有锁时间比较长的情况, 线程如果持有锁的时间很短带来的开销会很大。实际应用上可以把这两种锁结合起来使用,称为adaptive mutex。获取锁的操作分两步,第一步使用spinlock获取,当尝试几次还是获取不到时,再执行mutex操作。glibc的futex操作就使用这种机制来提高性能。

其实无论是spinlock还是mutex,基于硬件指令的实现本身性能都是很高的。在应用层面需要注意的是尽量少的避免资源竞争,多线程之间大量的资源竞争无论是线程空转消耗CPU还是上下文频繁切换都会影响程序性能。Lock are’t slow, lock contention is。

锁状态层面

1. 死锁

死锁是已经进入等待状态的线程相互等待各自锁释放的一种循环等待的状态。如下图,线程1和线程2同时获取Lock#1和Lock#2才能运行,某个时刻,线程1拥有Lock#1, 线程2拥有Lock#2,各自又分别在等待Lock#2和Lock#1,从而进入死锁状态。死锁可以被预防和检测。

enter image description here

2. 活锁

活锁和死锁产生的条件一样,只是表现不同。死锁下线程进入等待状态,而活锁下线程仍然处于运行状态尝试获取锁。活锁下线程仍然消耗CPU,这样看来,活锁和死锁的区别也有点类似spinlock和mutex。

锁策略层面

锁的存在是为了保护临界资源,操作系统层面提供spinlock和mutex两种方式,需要考虑的是怎样实现以保证锁的高效。而应用层考虑的是如何更高效的使用他们, 使用什么样的策略去保护不同的临界资源。

1. 独占锁

独占锁(exclusive lock),有时候也被称为写锁,排它锁等。被独占锁保护的资源,同一时刻只能有一个线程可以进行读写操作。

2. 共享锁

共享锁(shared lock),有时候也被称为读锁。被共享锁保护的资源,当有线程写时仍然可以被别的线程读取,读线程数并不限定为1。但是同一时刻只能有一个线程写入。 两者的区别,在Stack Overflow上,看到个类比挺形象的: whats-the-difference-between-an-exclusive-lock-and-a-shared-lock

3. 可重入锁

可重入锁可以并且只能被已经持有锁的线程加锁多次,一个线程内有多个地方形成对该锁的嵌套获取时可以防止死锁发生。实现上可重入锁会记录自己被lock的次数,只有unlock的次数和lock次数相等时才完成对锁的释放。

4. 公平锁、非公平锁

公平锁顾名思义,申请锁的线程按照申请顺序来获取锁,遵循先申请先得到的原则。而非公平锁则没有该顺序保障。公平锁通常使用等待队列来实现,申请线程未获取到锁时则进入等待队列,锁被释放时得到通知或者直接由上个拥有者移交锁的所有权。

数据库系统层面

数据库系统中的锁用于事务控制,保证数据库的ACID特性,有乐观锁, 悲观锁和两段锁等。

1. 乐观锁

乐观锁是并发访问控制的一种模式,实际上并没有对任何资源加锁。乐观锁假定事务大部分情况下不会出现冲突,运行时不对资源加锁,而在commit时检测是否有冲突,发现有冲突时执行rollback并重启事务。资源竞争不激烈时采用乐观锁可以消除等待锁释放、加锁的开销,实现高吞吐量。资源竞争激列的情况下频繁的rollback和事务重启,效率会很差。 乐观锁通常采用给资源增加版本号的方式来实现。需要注意的是冲突检查和commit/rollback两个操作需要保证原子性,避免产生TOCTTOU问题,mysql上可以使用一个带条件的update语句实现比较和更新操作的原子性。

2. 悲观锁

悲观锁假定冲突一定会发生,写操作直接对资源加独占锁,其他事务的写操作必须等待。读操作时,其他事务不能修改资源但是可读。悲观锁能有效的避免事务冲突,但是不会产生冲突的事务操作也需要加锁进行导致性能会比较差。

3. 两段锁

数据库事务操作遵循两段锁协议来保证事务操作的串行化。两段锁协议分为两个阶段: a. 加锁阶段(Expanding phase), 只能获取锁,不能释放锁 b. 解锁阶段(Shrinking phase),修改数据然后释放锁,不能获取锁 参考:http://user.it.uu.se/~arnoldp/distrib/TwoPhase.html

JVM层面

1. 偏向锁、轻量级锁、重量级锁

这几种锁都是JVM对Monitor锁机制的底层优化或者说是tradoff。偏向锁是JDK 6引入的锁优化策略,线程执行同步块时,锁会偏向第一个访问它的线程,而且并不触发任何加锁操作(spinlock或Mutex),从而消除无竞争状态下的同步原语来提高性能。测试发现,无竞争访问情况下,偏向锁的性能约是普通锁的10倍以上。被偏向锁锁住的对象发生资源竞争时JVM会挂起持有锁的线程,并且尝试把偏向锁膨胀为轻量级锁。 轻量级锁是对重量级锁的一个优化。重量级锁是一个mutex操作,发生竞争时线程会产生阻塞,锁的频繁获取释放,会导致线程上下文的频繁切换,对系统性能带来很大影响。轻量级锁是一个基于CAS的spinlock实现。轻量级锁和重量级锁采用类似adaptive mutex的方式进行组合,线程先尝试几次获取轻量级锁,获取不到时再执行重量级锁的加锁策略来获取锁或进入等待队列。 轻量级锁依赖CAS实现,当需要wait, notify等操作时也会膨胀为支持这些操作的重量级锁。

enter image description here

2. 分段锁

分段锁通过把数据分段存储,缩小锁的粒度来提高对容器的并发访问性能,Java中的ConcurrentHashMap是对分段锁的一个经典实现。 每个Hash分段都分配一个可重入锁,对数据的增删改操作,必须要先获取所操作key的hash值所在分段的锁才能进行,InfoQ上有对这部分实现的详细讲解:深入分析ConcurrentHashMap。

分布式系统层面

1. 分布式锁

分布式锁不是进程内的锁,而是分布式系统中各个服务访问临界资源前需要获取的锁。大部分基于共享存储,利用外部数据系统提供的原子操作来实现,如基于Redis的SetNx操作实现, Mysql的条件更新操作, Zookeeper的原生操作支持等。利用分布式一致性协议,如Paxos, Raft等也可以实现分布式锁。

多核调度其实是单核调度算法的复制。多核的CPU其实互相是看不到对方的,CPU不过就是一条指令一条指令向下执行。所谓多核调度,就是每个CPU有一个run queue(下面简称rq),创建线程(下面简称task)的时候把线程扔到一个rq中,那个CPU就对这个rq执行单核的调度算法而已。

当然,那个仅仅是基础。多核调度还有一个任务是平衡调度。就是一个核特别忙,另一个核特别闲,就需要把task从一个核迁移到另一个核的runqueue中。

迁移要考虑的问题比前面说的这个模型复杂,因为有很多额外的要素要考虑:比如,一个核的两个超线程,它们共享相同的执行部件,很多时候平衡它们是没有意义的。又比如说,两个CPU,有不同的L2 Cache,你轻易切换它们,就有可能发生大量的Cache失效。还有,如果你把线程从NUMA系统的一个Node迁移到另一个,就把它的内存和它运行CPU拉远了,这会直接降低这个线程的执行性能。