一、沉默王二-并发编程
1、线程阻塞唤醒类LockSupport
LockSupprot 用来阻塞和唤醒线程,底层实现依赖于 Unsafe 类。
该类包含一组用于阻塞和唤醒线程的静态方法,这些方法主要是围绕 park 和 unpark 展开,话不多说,直接来看一个简单的例子吧。
java
public class LockSupportDemo1 {
public static void main(String[] args) {
Thread mainThread = Thread.currentThread();
// 创建一个线程从1数到1000
Thread counterThread = new Thread(() -> {
for (int i = 1; i <= 1000; i++) {
System.out.println(i);
if (i == 500) {
// 当数到500时,唤醒主线程
LockSupport.unpark(mainThread);
}
}
});
counterThread.start();
// 主线程调用park
LockSupport.park();
System.out.println("Main thread was unparked.");
}
}
上面的代码中,当 counterThread 数到 500 时,它会唤醒 mainThread。而 mainThread 在调用 park 方法时会被阻塞,直到被 unpark。
LockSupport 中的方法不多,这里将这些方法做一个总结:
1.1 阻塞线程
void park()
:阻塞当前线程,如果调用 unpark 方法或线程被中断,则该线程将变得可运行。请注意,park 不会抛出 InterruptedException,因此线程必须单独检查其中断状态。void park(Object blocker)
:功能同方法 1,入参增加一个 Object 对象,用来记录导致线程阻塞的对象,方便问题排查。void parkNanos(long nanos)
:阻塞当前线程一定的纳秒时间,或直到被 unpark 调用,或线程被中断。void parkNanos(Object blocker, long nanos)
:功能同方法 3,入参增加一个 Object 对象,用来记录导致线程阻塞的对象,方便问题排查。void parkUntil(long deadline)
:阻塞当前线程直到某个指定的截止时间(以毫秒为单位),或直到被 unpark 调用,或线程被中断。void parkUntil(Object blocker, long deadline)
:功能同方法 5,入参增加一个 Object 对象,用来记录导致线程阻塞的对象,方便问题排查。
1.2 唤醒线程
void unpark(Thread thread)
:唤醒一个由 park 方法阻塞的线程。如果该线程未被阻塞,那么下一次调用 park 时将立即返回。这允许"先发制人"式的唤醒机制。
实际上,LockSupport 阻塞和唤醒线程的功能依赖于 sun.misc.Unsafe
,这是一个很底层的类,,比如 LockSupport 的 park 方法是通过 unsafe.park()
方法实现的。
1.3 Dump 线程
"Dump 线程"通常是指获取线程的当前状态和调用堆栈的详细快照。这可以提供关于线程正在执行什么操作以及线程在代码的哪个部分的重要信息。
下面是线程转储中可能包括的一些信息:
- 线程 ID 和名称:线程的唯一标识符和可读名称。
- 线程状态:线程的当前状态,例如运行(RUNNABLE)、等待(WAITING)、睡眠(TIMED_WAITING)或阻塞(BLOCKED)。
- 调用堆栈:线程的调用堆栈跟踪,显示线程从当前执行点回溯到初始调用的完整方法调用序列。
- 锁信息:如果线程正在等待或持有锁,线程转储通常还包括有关这些锁的信息。
线程转储可以通过各种方式获得,例如使用 Java 的 jstack 工具,或从 Java VisualVM、Java Mission Control 等工具获取。
1.4 与 synchronzed 的区别
还有一点需要注意的是:synchronzed 会使线程阻塞,线程会进入 BLOCKED 状态,而调用 LockSupprt 方法阻塞线程会使线程进入到 WAITING 状态。
来一个简单的例子演示一下该怎么用。
java
public class LockSupportExample {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
System.out.println("Thread is parked now");
LockSupport.park();
System.out.println("Thread is unparked now");
});
thread.start();
try {
Thread.sleep(3000); // 主线程等待3秒
} catch (InterruptedException e) {
e.printStackTrace();
}
LockSupport.unpark(thread); // 主线程唤醒阻塞的线程
}
}
thread 线程调用 LockSupport.park()
使 thread 阻塞,当 main 线程睡眠 3 秒结束后通过 LockSupport.unpark(thread)
方法唤醒 thread 线程,thread 线程被唤醒后会执行后续的操作。另外,LockSupport.unpark(thread)
可以指定线程对象唤醒指定的线程。
运行结果:
java
Thread is parked now
Thread is unparked now
1.5 设计思路
LockSupport 的设计思路是通过许可证来实现的,就像汽车上高速公路,入口处要获取通行卡,出口处要交出通行卡,如果没有通行卡你就无法出站,当然你可以选择补一张通行卡。
LockSupport 会为使用它的线程关联一个许可证(permit)状态,permit 的语义「是否拥有许可」,0 代表否,1 代表是,默认是 0。
LockSupport.unpark
:指定线程关联的 permit 直接更新为 1,如果更新前的permit<1
,唤醒指定线程LockSupport.park
:当前线程关联的 permit 如果>0,直接把 permit 更新为 0,否则阻塞当前线程
来看时间线:
- 线程 A 执行
LockSupport.park
,发现 permit 为 0,未持有许可证,阻塞线程 A - 线程 B 执行
LockSupport.unpark
(入参线程 A),为 A 线程设置许可证,permit 更新为 1,唤醒线程 A - 线程 B 流程结束
- 线程 A 被唤醒,发现 permit 为 1,消费许可证,permit 更新为 0
- 线程 A 执行临界区
- 线程 A 流程结束
经过上面的分析得出结论 unpark 的语义明确为「使线程持有许可证」,park 的语义明确为「消费线程持有的许可」 ,所以 unpark 与 park 的执行顺序没有强制要求,只要控制好使用的线程即可,unpark=>park
执行流程如下
- permit 默认是 0,线程 A 执行 LockSupport.unpark,permit 更新为 1,线程 A 持有许可证
- 线程 A 执行 LockSupport.park,此时 permit 是 1,消费许可证,permit 更新为 0
- 执行临界区
- 流程结束
最后再补充下 park 的注意点,因 park 阻塞的线程不仅仅会被 unpark 唤醒,还可能会被线程中断(Thread.interrupt
)唤醒 ,而且不会抛出 InterruptedException 异常,所以建议在 park 后自行判断线程中断状态,来做对应的业务处理。
为什么推荐使用 LockSupport 来做线程的阻塞与唤醒(线程间协同工作),因为它具备如下优点:
- 以线程为操作对象更符合阻塞线程的直观语义
- 操作更精准,可以准确地唤醒某一个线程(notify 随机唤醒一个线程,notifyAll 唤醒所有等待的线程)
- 无需竞争锁对象 (以线程作为操作对象),不会因竞争锁对象产生死锁问题
- unpark 与 park 没有严格的执行顺序,不会因执行顺序引起死锁问题,比如「Thread.suspend 和 Thread.resume」没按照严格顺序执行,就会产生死锁
1.6 面试题
阿里面试官:有 3 个独立的线程,一个只会输出 A,一个只会输出 B,一个只会输出 C,在三个线程启动的情况下,请用合理的方式让他们按顺序打印 ABCABC。
java
public class ABCPrinter {
// 定义三个线程t1, t2, t3
private static Thread t1, t2, t3;
public static void main(String[] args) {
// 创建线程t1,打印字符'A'
t1 = new Thread(() -> {
for (int i = 0; i < 2; i++) {
// 当前线程t1进入等待状态
LockSupport.park();
// 打印字符'A'
System.out.print("A");
// 唤醒线程t2
LockSupport.unpark(t2);
}
});
// 创建线程t2,打印字符'B'
t2 = new Thread(() -> {
for (int i = 0; i < 2; i++) {
// 当前线程t2进入等待状态
LockSupport.park();
// 打印字符'B'
System.out.print("B");
// 唤醒线程t3
LockSupport.unpark(t3);
}
});
// 创建线程t3,打印字符'C'
t3 = new Thread(() -> {
for (int i = 0; i < 2; i++) {
// 当前线程t3进入等待状态
LockSupport.park();
// 打印字符'C'
System.out.print("C");
// 唤醒线程t1
LockSupport.unpark(t1);
}
});
// 启动线程t1, t2, t3
t1.start();
t2.start();
t3.start();
// 主线程稍微等待一下,确保其他线程已经启动并且进入park状态。
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 启动整个流程,唤醒线程t1
LockSupport.unpark(t1);
}
}
这里的实现方式是:
- 我们首先为每个线程创建一个 Runnable,使其在循环中 park 自身 ,然后输出其对应的字符,并 unpark 下一个线程。
- 所有线程在启动后会先调用 park 将自己阻塞。
- 主线程稍微延迟后调用 t1 的 unpark,启动整个打印流程。 这样可以保证每个线程按照预期的顺序进行工作。
2、Java的并发容器
Java 的并发集合容器提供了在多线程环境中高效访问和操作的数据结构 。这些容器通过内部的同步机制实现了线程安全,使得开发者无需显式同步代码就能在并发环境下安全使用,比如说:ConcurrentHashMap、阻塞队列和 CopyOnWrite 容器等。
java.util 包下提供了一些容器类(集合框架),其中 Vector 和 Hashtable 是线程安全的,但实现方式比较粗暴,通过在方法上加「sychronized」关键字实现。
但即便是 Vector 这样线程安全的类,在应对多线程的复合操作时也需要在客户端继续加锁以保证原子性。
2.1 并发容器类
整体架构如下图所示:
2.2 并发 Map
2.2.1 ConcurrentMap 接口
ConcurrentMap 接口继承了 Map 接口,在 Map 接口的基础上又定义了四个方法:
java
public interface ConcurrentMap<K, V> extends Map<K, V> {
//插入元素
V putIfAbsent(K key, V value);
//移除元素
boolean remove(Object key, Object value);
//替换元素
boolean replace(K key, V oldValue, V newValue);
//替换元素
V replace(K key, V value);
}
putIfAbsent: 与原有 put 方法不同的是,putIfAbsent 如果插入的 key 相同,则不替换原有的 value 值;
remove: 与原有 remove 方法不同的是,新 remove 方法中增加了对 value 的判断,如果要删除的 key-value 不能与 Map 中原有的 key-value 对应上,则不会删除该元素;
replace(K,V,V): 增加了对 value 值的判断,如果 key-oldValue 能与 Map 中原有的 key-value 对应上,才进行替换操作;
replace(K,V): 与上面的 replace 不同的是,此 replace 不会对 Map 中原有的 key-value 进行比较,如果 key 存在则直接替换;
2.2.2 ConcurrentHashMap
ConcurrentHashMap 同 HashMap一样,也是基于散列表的 map,但是它提供了一种与 Hashtable 完全不同的加锁策略,提供了更高效的并发性和伸缩性。
2.2.3 ConcurrentSkipListMap
ConcurrentNavigableMap 接口继承了 NavigableMap 接口,这个接口提供了针对给定搜索目标返回最接近匹配项的导航方法。
ConcurrentNavigableMap 接口的主要实现类是 ConcurrentSkipListMap 类。从名字上来看,它的底层使用的是跳表(SkipList)。跳表是一种"空间换时间"的数据结构,可以使用 CAS 来保证并发安全性。
与 ConcurrentHashMap 的读密集操作相比,ConcurrentSkipListMap 的读和写操作的性能相对较低。这是由其数据结构导致的,因为跳表的插入和删除需要更复杂的指针操作。然而,ConcurrentSkipListMap 提供了有序性,这是 ConcurrentHashMap 所没有的。
ConcurrentSkipListMap 适用于需要线程安全的同时又需要元素有序的场合。如果不需要有序,ConcurrentHashMap 可能是更好的选择,因为它通常具有更高的性能。
2.3 并发 Queue
JDK 并没有提供线程安全的 List 类,因为对 List 来说,很难去开发一个通用并且没有并发瓶颈的线程安全的 List 。因为即使简单的读操作,比如 contains()
,也需要再搜索的时候锁住整个 list。
所以退一步,JDK 提供了队列和双端队列的线程安全类 :ConcurrentLinkedQueue 和 ConcurrentLinkedDeque。因为队列相对于 List 来说,有更多的限制。这两个类是使用 CAS 来实现线程安全的。
2.4 并发 Set
ConcurrentSkipListSet 是线程安全的有序集合。底层是使用 ConcurrentSkipListMap 来实现。
Set 日常开发中用的并不多,所以这里就不展开细讲了。
2.5 阻塞队列
我们假设一种场景,生产者一直生产资源,消费者一直消费资源,资源存储在一个缓冲池中,生产者将生产的资源存进缓冲池中,消费者从缓冲池中拿到资源进行消费,这就是大名鼎鼎的生产者-消费者模式。
该模式能够简化开发过程,一方面消除了生产者类与消费者类之间的代码依赖性,另一方面将生产数据的过程与使用数据的过程解耦简化负载。
我们自己 coding 实现这个模式的时候,因为需要让多个线程操作共享变量 (即资源),所以很容易引发线程安全问题 ,造成重复消费 和死锁 ,尤其是生产者和消费者存在多个的情况。另外,当缓冲池空了,我们需要阻塞消费者,唤醒生产者;当缓冲池满了,我们需要阻塞生产者,唤醒消费者,这些个等待-唤醒逻辑都需要自己实现。
这么容易出错的事情,JDK 当然帮我们做啦,这就是阻塞队列(BlockingQueue),你只管往里面存、取就行,而不用担心多线程环境下存、取共享变量的线程安全问题。
BlockingQueue 是 Java util.concurrent 包下重要的数据结构,区别于普通的队列,BlockingQueue 提供了线程安全的队列访问方式,并发包下很多高级同步类的实现都是基于 BlockingQueue 实现的。
BlockingQueue 一般用于生产者-消费者模式,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程 。BlockingQueue 就是存放元素的容器。
2.5.1 BlockingQueue 的操作方法
阻塞队列提供了四组不同的方法用于插入、移除、检查元素:
方法\处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除方法 | remove() | poll() | take() | poll(time,unit) |
检查方法 | element() | peek() | - | - |
- 抛出异常 :如果操作无法立即执行,会抛异常。当阻塞队列满时候,再往队列里插入元素,会抛出
IllegalStateException("Queue full")
异常。当队列为空时,从队列里获取元素时会抛出 NoSuchElementException 异常 。 - 返回特殊值:如果操作无法立即执行,会返回一个特殊值,通常是 true / false。
- 一直阻塞:如果操作无法立即执行,则一直阻塞或者响应中断。
- 超时退出:如果操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功,通常是 true / false。
注意:
- 不能往阻塞队列中插入 null,会抛出空指针异常。
- 可以访问阻塞队列中的任意元素 ,调用
remove(o)
可以将队列之中的特定对象移除,但并不高效,尽量避免使用。
2.5.2 BlockingQueue 的实现类
ArrayBlockingQueue
由数组 结构组成的有界阻塞队列。内部结构是数组,具有数组的特性。
java
public ArrayBlockingQueue(int capacity, boolean fair){
//..省略代码
}
可以初始化队列大小,一旦初始化将不能改变。构造方法中的 fair 表示控制对象的内部锁是否采用公平锁,默认是非公平锁。
LinkedBlockingQueue
由链表 结构组成的有界 阻塞队列。内部结构是链表,具有链表的特性。默认队列的大小是Integer.MAX_VALUE
,也可以指定大小。此队列按照先进先出的原则对元素进行排序。
DelayQueue
该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。注入其中的元素必须实现 java.util.concurrent.Delayed
接口。
DelayQueue 是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
PriorityBlockingQueue
基于优先级的无界阻塞队列(优先级的判断通过构造函数传入的 Compator 对象来决定),内部控制线程同步的锁采用的是非公平锁。
网上大部分博客上PriorityBlockingQueue为公平锁,其实是不对的,查阅源码:
java
public PriorityBlockingQueue(int initialCapacity,
Comparator<? super E> comparator) {
this.lock = new ReentrantLock(); //默认构造方法-非公平锁
...//其余代码略
}
SynchronousQueue
这个队列比较特殊,没有任何内部容量 ,甚至连一个队列的容量都没有。并且每个 put 必须等待一个 take,反之亦然。
需要区别容量为 1 的 ArrayBlockingQueue、LinkedBlockingQueue。
以下方法的返回值,可以帮助理解这个队列:
iterator()
永远返回空,因为里面没有东西peek()
永远返回 nullput()
往 queue 放进去一个 element 以后就一直 wait 直到有其他 thread 进来把这个 element 取走。offer()
往 queue 里放一个 element 后立即返回,如果碰巧这个 element 被另一个 thread 取走了,offer 方法返回 true,认为 offer 成功;否则返回 false。take()
取出并且 remove 掉 queue 里的 element,取不到东西他会一直等。poll()
取出并且 remove 掉 queue 里的 element,只有到碰巧另外一个线程正在往 queue 里 offer 数据或者 put 数据的时候,该方法才会取到东西。否则立即返回 null。isEmpty()
永远返回 trueremove()&removeAll()
永远返回 false
注意
PriorityBlockingQueue 不会阻塞数据生产者(因为队列是无界的),而只会在没有可消费的数据时阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。对于使用默认大小的LinkedBlockingQueue也是一样的。
2.6 CopyOnWrite 容器
在聊 CopyOnWrite 容器之前我们先来谈谈什么是 CopyOnWrite 机制,CopyOnWrite 是计算机设计领域的一种优化策略,也是一种在并发场景下常用的设计思想------写入时复制。
什么是写入时复制呢?
就是当有多个调用者同时去请求一个资源数据的时候,有一个调用者出于某些原因需要对当前的数据源进行修改,这个时候系统将会复制一个当前数据源的副本给调用者修改。
CopyOnWrite 容器即写时复制的容器,当我们往一个容器中添加元素的时候,不直接往容器中添加,而是将当前容器进行 copy,复制出来一个新的容器,然后向新容器中添加我们需要的元素,最后将原容器的引用指向新容器。
这样做的好处在于,我们可以在并发的场景下对容器进行"读操作"而不需要"加锁",从而达到读写分离的目的。从 JDK 1.5 开始 Java 并发包里提供了两个使用 CopyOnWrite 机制实现的并发容器,分别是 CopyOnWriteArrayList 和 CopyOnWriteArraySet。
二、小林-图解系统-进程管理
1、进程调度
一旦操作系统把进程切换到运行状态,也就意味着该进程占用着 CPU 在执行,但是当操作系统把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程。
选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序 (scheduler)。
那到底什么时候调度进程,或以什么原则来调度进程呢?
线程不是操作系统的调度单位吗?为什么这里参与调度的是进程?
先提前说明,这里的进程指只有主线程的进程,所以调度主线程就等于调度了整个进程。
那为什么干脆不直接取名线程调度?主要是操作系统相关书籍,都是用进程调度这个名字。
1.1 调度时机
在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。
比如,以下状态的变化都会触发操作系统的调度:
- 从就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
- 从运行态 -> 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须选择另外一个进程运行;
- 从运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;
因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。
另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:
- 非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
- 抢占式调度算法 挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断 ,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制。
1.2 调度原则
总结成如下:
- CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
- 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
- 周转时间:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;
- 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
- 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。
说白了,这么多调度原则,目的就是要使得进程要「快」。
1.3 调度算法
不同的调度算法适用的场景也是不同的。
接下来,说说在单核 CPU 系统中常见的调度算法。
1.3.1 先来先服务调度算法
最简单的一个调度算法,就是非抢占式的先来先服务(First Come First Serve, FCFS)算法了。
顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
1.3.2 最短作业优先调度算法
最短作业优先(Shortest Job First, SJF)调度算法 同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
1.3.3 高响应比优先调度算法
前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。
那么,高响应比优先 (Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
从上面的公式,可以发现:
- 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
- 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
1.3.4 时间片轮转调度算法
最古老、最简单、最公平且使用最广的算法就是时间片轮转(Round Robin, RR)调度算法。
每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。
- 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外一个进程;
- 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
另外,时间片的长度就是一个很关键的点:
- 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
- 如果设得太长又可能引起对短作业进程的响应时间变长。将
一般来说,时间片设为 20ms~50ms
通常是一个比较合理的折中值。
1.3.5 最高优先级调度算法
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级Highest Priority First,HPF)调度算法。
进程的优先级可以分为,静态优先级和动态优先级:
- 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
- 动态优先级 :根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
1.3.6 多级反馈队列调度算法
多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
顾名思义:
- 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
- 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
来看看,它是如何工作的:
- 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低 ,同时优先级越高时间片越短;
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也变更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
2、进程间通信方式
每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。
Linux 内核提供了不少进程间通信的机制,我们来一起瞧瞧有哪些?
2.1 管道
如果你学过 Linux 命令,那你肯定很熟悉「|
」这个竖线。
sh
$ ps auxf | grep mysql
上面命令行里的「|
」竖线就是一个管道 ,它的功能是将前一个命令(ps auxf
)的输出,作为后一个命令(grep mysql
)的输入 ,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。
同时,我们得知上面这种管道是没有名字,所以「|
」表示的管道称为匿名管道,用完了就销毁。
管道还有另外一个类型是命名管道 ,也被叫做 FIFO
,因为数据是先进先出的传输方式。
在使用命名管道前,先需要通过 mkfifo
命令来创建,并且指定管道名字:
sh
$ mkfifo myPipe
myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思:
sh
$ ls -l
prw-r--r--. 1 root root 0 Jul 17 02:45 myPipe
接下来,我们往 myPipe 这个管道写入数据:
sh
$ echo "hello" > myPipe // 将数据写进管道
// 停住了 ...
你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。
于是,我们执行另外一个命令来读取这个管道里的数据:
java
$ cat < myPipe // 读取管道里的数据
hello
可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。
我们可以看出,管道这种通信方式效率低,不适合进程间频繁地交换数据。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。
那管道如何创建呢,背后原理是什么?
匿名管道的创建,需要通过下面这个系统调用:
c
int pipe(int fd[2])
这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0]
,另一个是管道的写入端描述符 fd[1]
。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
其实,所谓的管道,就是内核里面的一串缓存 。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。
看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?
我们可以使用 fork
创建子进程,创建的子进程会复制父进程的文件描述符 ,这样就做到了两个进程各有两个「 fd[0]
与 fd[1]
」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。
管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入,也都可以读出。那么,为了避免这种情况,通常的做法是:
- 父进程关闭读取的 fd[0],只保留写入的 fd[1];
- 子进程关闭写入的 fd[1],只保留读取的 fd[0];
所以说如果需要双向通信,则应该创建两个管道。
到这里,我们仅仅解析了使用管道进行父进程与子进程之间的通信,但是在我们 shell 里面并不是这样的。
在 shell 里面执行 A | B
命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。
所以说,在 shell 里通过「|
」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。
我们可以得知,对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。
另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
2.2 消息队列
前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。
对于这个问题,消息队列的通信模式就可以解决。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。
再来,消息队列是保存在内核中的消息链表 ,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
但邮件的通信方式存在不足的地方有两点,一是通信不及时,二是附件也有大小限制,这同样也是消息队列通信不足的点。
消息队列不适合比较大数据的传输 ,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX
和 MSGMNB
,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
2.3 共享内存
消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝 过程。那共享内存的方式,就很好的解决了这一问题。
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
2.4 信号量
用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。
为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量表示资源的数量,控制信号量的方式有两种原子操作:
- 一个是 P 操作 ,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
- 另一个是 V 操作 ,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1
。
具体的过程如下:
- 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
- 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
- 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。
可以发现,信号初始化为 1
,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。
那么这时候,就可以用信号量来实现多进程同步的方式 ,我们可以初始化信号量为 0
。
具体过程:
- 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
- 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
- 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
可以发现,信号初始化为 0
,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。
2.5 信号
上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。
信号跟信号量虽然名字相似度 66.66%,但两者用途完全不一样,就好像 Java 和 JavaScript 的区别。
在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。
所以,信号事件的来源主要有硬件来源 (如键盘 Cltr+C )和软件来源(如 kill 命令)。
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。
2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
3.忽略信号 。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL
和 SEGSTOP
,它们用于在任何时候中断或结束某一进程。
2.6 Socket
前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。
实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。
我们来看看创建 socket 的系统调用:
c
int socket(int domain, int type, int protocal)
三个参数分别代表:
- domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;
- type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
- protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;
根据创建 socket 类型的不同,通信的方式也就不同:
- 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;
- 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
- 实现本地进程间通信: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;
接下来,简单说一下这三种通信的编程模式。
2.6.1 针对 TCP 协议通信的 socket 编程模型
- 服务端和客户端初始化
socket
,得到文件描述符; - 服务端调用
bind
,将绑定在 IP 地址和端口; - 服务端调用
listen
,进行监听; - 服务端调用
accept
,等待客户端连接; - 客户端调用
connect
,向服务器端的地址和端口发起连接请求; - 服务端
accept
返回用于传输的socket
的文件描述符; - 客户端调用
write
写入数据 ;服务端调用read
读取数据; - 客户端断开连接时,会调用
close
,那么服务端read
读取数据的时候,就会读取到了EOF
,待处理完数据后,服务端调用close
,表示连接关闭。
这里需要注意的是,服务端调用 accept
时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket ,一个叫作监听 socket ,一个叫作已完成连接 socket。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
2.6.2 针对 UDP 协议通信的 socket 编程模型
UDP 是没有连接的 ,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。
对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。
另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。
2.6.3 针对本地进程间通信的 socket 编程模型
本地 socket 被用于在同一台主机上进程间通信的场景:
- 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;
- 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;
对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。
对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。
本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。