一、沉默王二-并发编程
1、重入锁ReentrantLock
ReentrantLock 重入锁,是实现Lock 接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源重复加锁,即当前线程获取该锁后再次获取不会被阻塞。
要想支持重入性,就要解决两个问题:
- 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;
- 由于锁会被获取 n 次,那么只有锁在被释放同样的 n 次之后,该锁才算是完全释放成功。
我们知道,同步组件主要是通过重写 AQS 的几个 protected 方法来表达自己的同步语义。
1.1 ReentrantLock 的源码分析
针对第一个问题,我们来看看 ReentrantLock 是怎样实现的,以非公平锁为例,判断当前线程能否获得锁为例,核心方法为内部类 Sync 的 nonfairTryAcquire 方法:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread(); // 获取当前线程
int c = getState(); // 获取当前锁的状态
// 1. 如果该锁未被任何线程占有,该锁能被当前线程获取
if (c == 0) {
if (compareAndSetState(0, acquires)) { // 尝试将状态从0设置为acquires
setExclusiveOwnerThread(current); // 设置当前线程为独占锁的拥有者
return true; // 获取锁成功
}
}
// 2. 若被占有,检查占有线程是否是当前线程
else if (current == getExclusiveOwnerThread()) {
// 3. 再次获取,计数加一
int nextc = c + acquires;
if (nextc < 0) // 检查是否溢出
throw new Error("Maximum lock count exceeded"); // 抛出错误,锁计数超出最大值
setState(nextc); // 设置新的状态值
return true; // 再次获取锁成功
}
return false; // 获取锁失败
}
这段代码的逻辑很简单,具体请看注释。为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程占有了,会继续检查占有线程是否为当前线程,如果是的话,同步状态加 1 返回 true,表示可以再次获取成功。每次重新获取都会对同步状态进行加一的操作,那么释放的时候处理思路是怎样的呢?(依然还是以非公平锁为例)核心方法为 tryRelease:
protected final boolean tryRelease(int releases) {
// 1. 同步状态减1
int c = getState() - releases;
// 2. 检查当前线程是否为持有锁的线程
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 3. 如果同步状态减到0,表示锁已经完全释放
if (c == 0) {
free = true;
// 4. 设置持有锁的线程为null
setExclusiveOwnerThread(null);
}
// 5. 更新同步状态
setState(c);
// 6. 返回锁是否完全释放的标志
return free;
}
代码的逻辑请看注释,需要注意的是,重入锁的释放必须得等到同步状态为 0 时锁才算成功释放,否则锁仍未释放。如果锁被获取了 n 次,释放了 n-1 次,该锁未完全释放返回 false,只有被释放 n 次才算成功释放,返回 true。到现在我们可以理清 ReentrantLock 重入性的实现了,也就是理解了同步语义的第一条。
ReentrantLock 支持两种锁:公平锁和非公平锁。何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足 FIFO。ReentrantLock 的构造方法无参时是构造非公平锁,源码为:
public ReentrantLock() {
sync = new NonfairSync();
}
另外还提供了一种方式,可传入一个 boolean 值,true 时为公平锁,false 时为非公平锁,源码为:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
在非公平锁获取时(nonfairTryAcquire 方法),只是简单的获取了一下当前状态然后做了一些逻辑处理,并没有考虑到当前同步队列中线程等待的情况。
我们来看看公平锁的处理逻辑是怎样的,核心方法为:
protected final boolean tryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取当前锁的状态
int c = getState();
// 如果锁当前没有被占用
if (c == 0) {
// 如果同步队列中没有有前驱节点,并且成功将状态从0更新为acquires
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
// 设置当前线程为独占锁的拥有者
setExclusiveOwnerThread(current);
// 返回获取锁成功
return true;
}
}
// 如果当前线程已经持有锁
else if (current == getExclusiveOwnerThread()) {
// 计算新的锁状态
int nextc = c + acquires;
// 如果锁计数溢出,抛出错误
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
// 更新锁状态
setState(nextc);
// 返回获取锁成功
return true;
}
// 如果以上条件都不满足,返回获取锁失败
return false;
}
这段代码的逻辑与 nonfairTryAcquire 基本上一致,唯一的不同在于增加了 hasQueuedPredecessors 的逻辑判断,从方法名就可以知道该方法用来判断当前节点在同步队列中是否有前驱节点的,如果有前驱节点,说明有线程比当前线程更早的请求资源,根据公平性,当前线程请求资源失败。如果当前节点没有前驱节点,才有做后面逻辑判断的必要性。
公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁。
1.2 ReentrantLock 的使用
ReentrantLock 的使用方式与 synchronized关键字类似,都是通过加锁和释放锁来实现同步的。我们来看看 ReentrantLock 的使用方式,以非公平锁为例:
public class ReentrantLockTest {
// 创建一个ReentrantLock实例
private static final ReentrantLock lock = new ReentrantLock();
// 定义一个静态变量count,用于计数
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 创建第一个线程
Thread thread1 = new Thread(() -> {
// 循环10000次
for (int i = 0; i < 10000; i++) {
// 获取锁
lock.lock();
try {
// count加1
count++;
} finally {
// 释放锁
lock.unlock();
}
}
});
// 创建第二个线程
Thread thread2 = new Thread(() -> {
// 循环10000次
for (int i = 0; i < 10000; i++) {
// 获取锁
lock.lock();
try {
// count加1
count++;
} finally {
// 释放锁
lock.unlock();
}
}
});
// 启动第一个线程
thread1.start();
// 启动第二个线程
thread2.start();
// 等待第一个线程结束
thread1.join();
// 等待第二个线程结束
thread2.join();
// 打印最终的count值
System.out.println(count);
}
}
代码很简单,两个线程分别对 count 变量进行 10000 次累加操作,最后输出 count 的值。我们来看看运行结果:
20000
可以看到,两个线程对 count 变量进行了 20000 次累加操作,说明ReentrantLock 是支持重入性的。我们再来看看公平锁的使用方式,只需要将 ReentrantLock 的构造方法改为公平锁即可:
private static final ReentrantLock lock = new ReentrantLock(true);
运行结果为:
20000
可以看到,公平锁的运行结果与非公平锁的运行结果一致,这是因为公平锁的实现方式与非公平锁的实现方式基本一致,只是在获取锁时增加了判断当前节点是否有前驱节点的逻辑判断。
- 公平锁: 按照线程请求锁的顺序获取锁,即先到先得。
- 非公平锁: 线程获取锁的顺序可能与请求锁的顺序不同,可能导致某些线程获取锁的速度较快。
需要注意的是,使用 ReentrantLock 时,锁必须在 try 代码块开始之前获取,并且加锁之前不能有异常抛出,否则在 finally 块中就无法释放锁(ReentrantLock 的锁必须在 finally 中手动释放)。
1.3 ReentrantLock 与 synchronized
ReentrantLock 与 synchronized 关键字都是用来实现同步的,那么它们之间有什么区别呢?我们来看看它们的对比:
- ReentrantLock 是一个类,而 synchronized 是 Java 中的关键字;
- ReentrantLock 可以实现多路选择通知(可以绑定多个 Condition),而 synchronized 只能通过 wait 和 notify/notifyAll 方法唤醒一个线程或者唤醒全部线程(单路通知) ;
- ReentrantLock 必须手动释放锁。通常需要在 finally 块中调用 unlock 方法以确保锁被正确释放。
- synchronized 会自动释放锁,当同步块执行完毕时,由 JVM 自动释放,不需要手动操作。
- ReentrantLock: 通常提供更好的性能,特别是在高竞争环境下。
- synchronized: 在某些情况下,性能可能稍差一些,但随着 JDK 版本的升级,性能差距已经不大了。
2、Java并发读写锁ReentrantReadWriteLock
ReentrantReadWriteLock 是 Java 的一种读写锁,它允许多个读线程同时访问,但只允许一个写线程访问(会阻塞所有的读写线程)。这种锁的设计可以提高性能,特别是在读操作的数量远远超过写操作的情况下。
在并发场景中,为了解决线程安全问题,我们通常会使用关键字 synchronized或者 JUC 包中实现了 Lock 接口的 ReentrantLock。但它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。
而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响数据正确性,而如果在这种业务场景下,依然使用独占锁的话,很显然会出现性能瓶颈。针对这种读多写少的情况,Java 提供了另外一个实现 Lock 接口的 ReentrantReadWriteLock——读写锁。
读写锁允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞。
在分析 WirteLock 和 ReadLock 的互斥性时,我们可以按照 WriteLock 与 WriteLock,WriteLock 与 ReadLock 以及 ReadLock 与 ReadLock 进行对比分析。
这里总结一下读写锁的特性:
1)公平性选择:支持非公平性(默认)和公平的锁获取方式,非公平的吞吐量优于公平;
在计算机科学和性能评估中,吞吐量(Throughput)是一个衡量系统处理能力的指标。它描述了单位时间内系统能够处理的事务或操作数量。吞吐量可以用来评估系统的效率和性能,例如,每秒钟完成多少次请求或操作。
非公平锁不保证等待获取锁的线程的顺序。当锁被释放时,哪个线程能够获取该锁并不遵循任何特定的顺序。这种方式通常效率较高,因为线程不需要按照队列顺序等待,从而可以减少上下文切换和调度开销,提高吞吐量。
公平锁则确保等待获取锁的线程将按照它们请求锁的顺序来获取锁。第一个请求锁的线程将是第一个获得锁的线程,以此类推。虽然公平锁的行为更容易预测,但由于需要维护一个明确的队列顺序,可能会增加额外的开销,从而降低吞吐量。
2)重入性:支持重入,读锁获取后能再次获取,写锁获取之后能够再次获取写锁,同时也能够获取读锁;
3)锁降级:写锁降级是一种允许写锁转换为读锁的过程。通常的顺序是:
- 获取写锁:线程首先获取写锁,确保在修改数据时排它访问。
- 获取读锁:在写锁保持的同时,线程可以再次获取读锁。
- 释放写锁:线程保持读锁的同时释放写锁。
- 释放读锁:最后线程释放读锁。
这样,写锁就降级为读锁,允许其他线程进行并发读取,但仍然排除其他线程的写操作。下面的代码展示了如何使用 ReentrantReadWriteLock 来降级写锁:
// 创建一个可重入的读写锁对象
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 获取该读写锁的写锁对象
ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
// 获取该读写锁的读锁对象
ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
// 获取写锁
writeLock.lock();
try {
// 执行写操作
// 在持有写锁的情况下尝试获取读锁
readLock.lock();
} finally {
// 释放写锁
writeLock.unlock();
}
// 尝试执行读操作
try {
// 执行读操作
} finally {
// 释放读锁
readLock.unlock();
}
写锁降级为读锁的过程有助于保持数据的一致性,而不影响并发读取的性能。通过这种方式,线程可以继续保持对数据的独占访问权限,直到它准备允许其他线程共享读取访问。这样可以确保在写操作和随后的读操作之间的数据一致性,并且允许其他读取线程并发访问。
要想彻底理解读写锁必须能够理解这几个问题:
-
- 读写锁是怎样实现分别记录读写状态的?
-
- 写锁是怎样获取和释放的?
-
- 读锁是怎样获取和释放的?
我们带着这样的三个问题,再去了解下读写锁。
2.1 写锁详解
2.1.1 写锁的获取
同一时刻,ReentrantReadWriteLock 的写锁是不能被多个线程获取的,很显然 ReentrantReadWriteLock 的写锁是独占式锁,而实现写锁的同步语义是通过重写 AQS中的 tryAcquire 方法实现的。源码为:
protected final boolean tryAcquire(int acquires) {
/*
* 检查流程:
* 1. 如果读锁计数非零或写锁计数非零
* 并且所有者是不同线程,则失败。
* 2. 如果计数将达到上限,则失败。(这只能在
* 计数已经非零的情况下发生。)
* 3. 否则,如果当前线程是可重入获取或
* 队列策略允许,则当前线程有资格获取锁。如果是,
* 更新状态并设置所有者。
*/
Thread current = Thread.currentThread();
// 1. 获取写锁当前的同步状态
int c = getState();
// 2. 获取写锁获取的次数
int w = exclusiveCount(c);
if (c != 0) {
// (注意:如果 c != 0 且 w == 0,则共享计数 != 0)
//当读锁已被读线程获取或者当前线程不是已经获取写锁的线程的话
// 当前线程获取写锁失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("超过最大锁计数");
// 可重入获取
//当前线程获取写锁,支持可重复加锁
setState(c + acquires);
return true;
}
// 3.3 写锁未被任何线程获取,当前线程可获取写锁
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
tryAcquire,其主要逻辑为:当读锁已经被读线程获取或者写锁已经被其他写线程获取,则写锁获取失败;否则,获取成功并支持重入,增加写状态。
2.1.2 写锁的释放
写锁释放通过重写 AQS 的 tryRelease 方法,源码为:
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//1. 同步状态减去写状态
int nextc = getState() - releases;
//2. 当前写状态是否为0,为0则释放写锁
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
//3. 不为0则更新同步状态
setState(nextc);
return free;
}
源码的实现逻辑请看注释,不难理解,与 ReentrantLock 基本一致,这里需要注意的是,减少写状态 int nextc = getState() - releases; 只需要用当前同步状态直接减去写状态,原因正是我们刚才所说的写状态是由同步状态的低 16 位表示的。
2.2 读锁详解
2.2.1 读锁的获取
看完了写锁,再来看看读锁,读锁不是独占式锁,即同一时刻该锁可以被多个读线程获取,也就是一种共享式锁。按照之前对 AQS 的介绍,实现共享式同步组件的同步语义需要通过重写 AQS 的 tryAcquireShared 方法和 tryReleaseShared 方法。读锁的获取实现方法为:
protected final int tryAcquireShared(int unused) {
/*
* 检查流程:
* 1. 如果写锁被其他线程持有,则失败。
* 2. 否则,当前线程根据状态有资格获取锁,
* 因此检查队列策略是否需要阻塞。如果不需要,
* 尝试通过CAS操作状态和更新计数来授予锁。
* 注意这一步不检查可重入获取,将其推迟到完整版本,
* 以避免在更常见的非可重入情况下检查保持计数。
* 3. 如果第二步失败,要么因为线程显然没有资格,
* 要么CAS失败,要么计数饱和,则链接到带有完整重试循环的版本。
*/
Thread current = Thread.currentThread(); // 获取当前线程
int c = getState(); // 获取当前锁的状态
// 1. 如果写锁已经被获取并且获取写锁的线程不是当前线程的话,当前线程获取读锁失败返回-1
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c); // 获取共享锁(读锁)的计数
if (!readerShouldBlock() && // 检查是否应该阻塞读锁
r < MAX_COUNT && // 检查读锁计数是否达到最大值
// 2. 当前线程尝试获取读锁
compareAndSetState(c, c + SHARED_UNIT)) { // 尝试通过CAS增加共享锁计数
// 3. 如果是第一个获取读锁的线程,或者当前线程已经获取过读锁,更新相关计数
if (r == 0) {
firstReader = current; // 第一个读锁线程设置为当前线程
firstReaderHoldCount = 1; // 设置第一个读锁线程的持有计数为1
} else if (firstReader == current) {
firstReaderHoldCount++; // 如果当前线程是第一个读锁线程,增加其持有计数
} else {
HoldCounter rh = cachedHoldCounter; // 获取缓存的保持计数器
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get(); // 如果缓存为空或tid不匹配,则从readHolds获取
else if (rh.count == 0)
readHolds.set(rh); // 如果计数为0,则设置到readHolds
rh.count++; // 增加保持计数
}
return 1; // 获取读锁成功,返回1
}
// 4. 如果第二步中的CAS操作失败,或者需要阻塞,则调用fullTryAcquireShared进行完整尝试
return fullTryAcquireShared(current);
}
代码的逻辑请看注释,需要注意的是 当写锁被其他线程获取后,读锁获取失败,否则获取成功,会利用 CAS 更新同步状态。
另外,当前同步状态需要加上 SHARED_UNIT((1 << SHARED_SHIFT),即 0x00010000)的原因,我们在上面也说过了,同步状态的高 16 位用来表示读锁被获取的次数。
如果 CAS 失败或者已经获取读锁的线程再次获取读锁时,是靠 fullTryAcquireShared 方法实现的,这段代码就不展开说了,有兴趣可以看看。
2.2.2 读锁的释放
读锁释放的实现主要通过方法 tryReleaseShared,源码如下,主要逻辑请看注释:
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
// 获取当前线程
// 判断当前线程是否为第一个读取者
if (firstReader == current) {
// 断言第一个读取者的持有计数大于0
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
// 如果持有计数为1,则将第一个读取者设置为null
else
firstReaderHoldCount--;
// 否则,减少第一个读取者的持有计数
} else {
HoldCounter rh = cachedHoldCounter;
// 获取缓存的持有计数器
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
// 如果计数器为空或tid不匹配,则从ThreadLocal中获取
int count = rh.count;
// 获取计数器中的计数
if (count <= 1) {
readHolds.remove();
// 如果计数小于等于1,则从ThreadLocal中移除计数器
if (count <= 0)
throw unmatchedUnlockException();
// 如果计数小于等于0,则抛出解锁不匹配的异常
}
--rh.count;
// 减少计数器中的计数
}
for (;;) {
int c = getState();
// 获取当前同步状态
// 读锁释放 将同步状态减去读状态即可
int nextc = c - SHARED_UNIT;
// 计算释放读锁后的新状态
if (compareAndSetState(c, nextc))
// 使用CAS操作更新同步状态
// 释放读锁对其他读者没有影响,
// 但如果读锁和写锁都空闲了,可能会允许等待的写者继续执行
return nextc == 0;
// 如果新状态为0,则返回true,表示锁完全释放
}
}
2.3 锁降级
读写锁支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级,关于锁降级,下面的示例代码摘自 ReentrantWriteReadLock 源码:
void processCachedData() {
// 获取读锁
rwl.readLock().lock();
if (!cacheValid) {
// 在获取写锁之前必须释放读锁
rwl.readLock().unlock();
// 获取写锁
rwl.writeLock().lock();
try {
// 重新检查状态,因为在我们获取写锁之前可能有其他线程已经获取了写锁并改变了状态
if (!cacheValid) {
data = ... // 假设这里是从某个地方获取数据
cacheValid = true; // 设置缓存有效标志
}
// 在释放写锁之前降级,通过获取读锁
rwl.readLock().lock();
} finally {
// 释放写锁,仍然持有读锁
rwl.writeLock().unlock();
}
}
try {
// 使用数据
use(data);
} finally {
// 释放读锁
rwl.readLock().unlock();
}
}
这里的流程可以解释如下:
- 获取读锁:首先尝试获取读锁来检查某个缓存是否有效。
- 检查缓存:如果缓存无效,则需要释放读锁,因为在获取写锁之前必须释放读锁。
- 获取写锁:获取写锁以便更新缓存。此时,可能还需要重新检查缓存状态,因为在释放读锁和获取写锁之间可能有其他线程修改了状态。
- 更新缓存:如果确认缓存无效,更新缓存并将其标记为有效。
- 写锁降级为读锁:在释放写锁之前,获取读锁,从而实现写锁到读锁的降级。这样,在释放写锁后,其他线程可以并发读取,但不能写入。
- 使用数据:现在可以安全地使用缓存数据了。
- 释放读锁:完成操作后释放读锁。
这个流程结合了读锁和写锁的优点,确保了数据的一致性和可用性,同时允许在可能的情况下进行并发读取。使用读写锁的代码可能看起来比使用简单的互斥锁更复杂,但它提供了更精细的并发控制,可能会提高多线程应用程序的性能。
2.4 使用读写锁
ReentrantReadWriteLock 的使用非常简单,下面的代码展示了如何使用 ReentrantReadWriteLock 来实现一个线程安全的计数器:
public class Counter {
// 创建一个可重入的读写锁
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// 获取读锁
private final Lock r = rwl.readLock();
// 获取写锁
private final Lock w = rwl.writeLock();
// 初始化计数器
private int count = 0;
/**
* 获取当前计数器的值
* @return 当前计数器的值
*/
public int getCount() {
// 加读锁
r.lock();
try {
// 返回计数器的值
return count;
} finally {
// 释放读锁
r.unlock();
}
}
/**
* 增加计数器的值
*/
public void inc() {
// 加写锁
w.lock();
try {
// 计数器值加1
count++;
} finally {
// 释放写锁
w.unlock();
}
}
}
二、小林-图解系统-进程管理
1.进程、线程基础知识
1.1 进程
我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」(Process) 。
现在我们考虑有一个会读取硬盘文件数据的程序被执行了,那么当运行到读取文件的指令时,就会去从硬盘读取数据,但是硬盘的读写速度是非常慢的,那么在这个时候,如果 CPU 傻傻的等硬盘返回数据的话,那 CPU 的利用率是非常低的。
所以,当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到个中断,于是 CPU 再继续运行这个进程。
这种多个程序、交替执行的思想,就有 CPU 管理多个进程的初步想法。
对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒。
虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。
并发和并行有什么区别?
一图胜千言。
进程与程序的关系的类比
CPU 可以从一个进程(做菜)切换到另外一个进程(买可乐),在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行。
所以,可以发现进程有着「运行 - 暂停 - 运行」的活动规律。
1.1.1 进程的状态
在上面,我们知道了进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。
它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。
所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
上图中各个状态的意义:
- 运行状态(Running):该时刻进程占用 CPU;
- 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
- 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
当然,进程还有另外两个基本状态:
- 创建状态(new):进程正在被创建时的状态;
- 结束状态(Exit):进程正在从系统中消失时的状态;
于是,一个完整的进程状态的变迁如下图:
再来详细说明一下进程的状态变迁:
- NULL -> 创建状态:一个新进程被创建时的第一个状态;
- 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
- 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
- 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
- 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
- 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
- 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;
如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。
所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。
那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。
另外,挂起状态可以分为两种:
- 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
- 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;
这两种挂起状态加上前面的五种状态,就变成了七种状态变迁,见如下图:
导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:
- 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。
- 用户希望挂起一个程序的执行,比如在 Linux 中用
Ctrl+Z挂起进程;
1.1.2 进程的控制结构
在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。
PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。
PCB 具体包含什么信息呢?
进程描述信息:
- 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
- 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;
进程控制和管理信息:
- 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
- 进程优先级:进程抢占 CPU 时的优先级;
资源分配清单:
- 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。
CPU 相关信息:
- CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。
可见,PCB 包含信息还是比较多的。
每个 PCB 是如何组织的呢?
通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:
- 将所有处于就绪状态的进程链在一起,称为就绪队列;
- 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列;
- 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。
那么,就绪队列和阻塞队列链表的组织形式如下图:
除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。
1.1.3 进程的控制
我们熟知了进程的状态变迁和进程的数据结构 PCB 后,再来看看进程的创建、终止、阻塞、唤醒的过程,这些过程也就是进程的控制。
01 创建进程
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。
创建进程的过程如下:
- 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等;
- 为该进程分配运行时所必需的资源,比如内存资源;
- 将 PCB 插入到就绪队列,等待被调度运行;
02 终止进程
进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。
当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。
终止进程的过程如下:
- 查找需要终止的进程的 PCB;
- 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
- 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;
- 将该进程所拥有的全部资源都归还给操作系统;
- 将其从 PCB 所在队列中删除;
03 阻塞进程
当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
阻塞进程的过程如下:
- 找到将要被阻塞进程标识号对应的 PCB;
- 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
- 将该 PCB 插入到阻塞队列中去;
04 唤醒进程
进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。
如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。
唤醒进程的过程如下:
- 在该事件的阻塞队列中找到相应进程的 PCB;
- 将其从阻塞队列中移出,并置其状态为就绪状态;
- 把该 PCB 插入到就绪队列中,等待调度程序调度;
进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。
1.1.4 进程的上下文切换
各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。
在详细说进程上下文切换前,我们先来看看 CPU 上下文切换
大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。
任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。
所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。
再来,程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。
既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。
CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。
进程的上下文切换到底是切换什么呢?
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:
大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。
发生进程上下文切换有哪些场景?
- 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
以上,就是发生进程上下文切换的常见场景了。
1.2 线程
在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。
1.2.1 为什么使用线程?
我们举个例子,假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个:
- 从视频文件当中读取数据;
- 对读取的数据进行解压缩;
- 把解压缩后的视频数据播放出来;
对于单进程的实现方式,我想大家都会是以下这个方式:
对于单进程的这种方式,存在以下问题:
- 播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,
Read的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放; - 各个函数之间不是并发执行,影响资源的使用效率;
那改进成多进程的方式:
对于多进程的这种方式,依然会存在问题:
- 进程之间如何通信,共享数据?
- 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息;
那到底如何解决呢?需要有一种新的实体,满足以下特性:
- 实体之间可以并发运行;
- 实体之间共享相同的地址空间;
这个新的实体,就是线程( Thread ) ,线程之间可以并发运行且共享相同的地址空间。
1.2.2 什么是线程?
线程是进程当中的一条执行流程。
同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。
线程的优缺点?
线程的优点:
- 一个进程中可以同时存在多个线程;
- 各个线程之间可以并发执行;
- 各个线程之间可以共享地址空间和文件等资源;
线程的缺点:
- 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程奔溃不会造成进程崩溃)。
举个例子,对于游戏的用户设计,则不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程。
1.2.3 线程与进程的比较
线程与进程的比较如下:
- 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
- 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
- 线程能减少并发执行的时间和空间开销;
对于,线程相比进程能减少开销,体现在:
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
- 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
- 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高;
所以,不管是时间效率,还是空间效率线程比进程都要高。
1.2.4 线程的上下文切换
在前面我们知道了,线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位。
所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
对于线程和进程,我们可以这么理解:
- 当进程只有一个线程时,可以认为进程就等于线程;
- 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;
另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
线程上下文切换的是什么?
这还得看线程是不是属于同一个进程:
- 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
- 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;
所以,线程的上下文切换相比进程,开销要小很多。
1.2.5 线程的实现
主要有三种线程的实现方式:
- 用户线程(User Thread) :在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
- 内核线程(Kernel Thread) :在内核中实现的线程,是由内核管理的线程;
- 轻量级进程(LightWeight Process) :在内核中来支持用户线程;
那么,这还需要考虑一个问题,用户线程和内核线程的对应关系。
首先,第一种关系是多对一的关系,也就是多个用户线程对应同一个内核线程:
第二种是一对一的关系,也就是一个用户线程对应一个内核线程:
第三种是多对多的关系,也就是多个用户线程对应到多个内核线程:
用户线程如何理解?存在什么优势和缺陷?
用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。
所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
用户级线程的模型,也就类似前面提到的多对一的关系,即多个用户线程对应同一个内核线程,如下图所示:
用户线程的优点:
- 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
- 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快;
用户线程的缺点:
- 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。
- 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。
- 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢;
以上,就是用户线程的优缺点了。
那内核线程如何理解?存在什么优势和缺陷?
内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。
内核线程的模型,也就类似前面提到的一对一的关系,即一个用户线程对应一个内核线程,如下图所示:
内核线程的优点:
- 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
- 分配给线程,多线程的进程获得更多的 CPU 运行时间;
内核线程的缺点:
- 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;
- 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大;
以上,就是内核线程的优缺点了。
最后的轻量级进程如何理解?
轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。
在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。
在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种:
1 : 1,即一个 LWP 对应 一个用户线程;N : 1,即一个 LWP 对应多个用户线程;M : N,即多个 LWP 对应多个用户线程;
接下来针对上面这三种对应关系说明它们优缺点。先看下图的 LWP 模型:
1 : 1 模式
一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。
- 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP;
- 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。
N : 1 模式
多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。
- 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高;
- 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。
M : N 模式
根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。
- 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。
组合模式
如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。