5.1 进程、线程基础知识
- 并行和并发
- 并发:单核处理器交替处理多个任务
- 并行:多核处理器同时处理多个任务
- 现代多核处理器并发和并行共存
- 进程和线程:
- 进程:
- 是资源管理的基本单位,一个进程一个独立的资源
- 创建或撤销进程时,系统都要为之分配或回收系统资源,开销大
- 唯一标识:PCB(process control block)
- 包含如资源占用,进程名称,进程状态等信息
- 父子进程:一个进程 fork 出另一个进程,子进程可以继承父进程所有资源
- 会把主进程的页表复制一份给子进程,两者虚拟空间不同,但物理空间相同,省空间
- 但是子进程页表对应的属性是只读,如果发生写操作,会触发缺页中断,进行物理内存复制,重新映射,并设置为可读写,即写时复制(copy-on-write)
- 子进程通过 PPID 标识父进程
- 孤儿进程:父进程被终止,子进程变成孤儿进程,被 1 号进程管理
- 孤儿进程只有自己被终止才会停,1 号进程不会主动杀死它,因为它可能还在执行任务
- 1 号进程:守护进程,负责系统启动等
- 僵尸进程:子进程退出后等待父进程进行资源回收的状态,如果父进程一直不回收会浪费资源
- 如果父进程直接退出,会交由 1 号进程管理,1 号进程会进行回收
- 可以双 fork,A 进程 fork B 进程,B 进程 fork C 进程,杀死 B 进程,即 A 进程的子进程全交由 1 号进程管理,不用担心僵死进程,且不会破坏 copy on write
- top 命令可以看到 zombie 就是僵死进程
- 线程:
- 程序执行的基本单位,上下文切换比进程快(不需要切换共享部分,如虚拟内存)
- 多个线程共享一个进程的资源
- 主要是堆
- 程序计数器、栈私有,因为每个线程有自己的执行上下文
- 内核级线程:依赖于内核进行管理
- 轻量级进程(LWP):由内核支持用户线程,每个LWP 和线程一一对应
- 用户级线程:程序管理生命周期,内核不直接参与,比内核线程轻量,无需内核态和用户态切换
- 用户级线程和内核线程可以有:一对一,多对一,多对多的关系
- 对于 C 语言而言,一个线程崩溃会导致所属进程的所有线程崩溃,即程序终止,对 Java 不会,因为虚拟机捕获了
- 因为多个线程共享地址空间,某个线程对地址的非法访问可能导致危险操作,操作系统认为这个很严重,就直接让其崩溃
- 因此游戏不建议使用多线程,否则一个用户挂了,其他用户也会崩溃
- Java 虚拟机如果收到 kill -15,会优雅退出,先进行资源清理
- -9 JVM 也无法捕获恢复,会立刻退出,不优雅
- 状态:
- 创建、就绪、运行、阻塞、结束
- 临界区:一次仅允许一个进程使用的资源称为临界资源,一般通过锁解决
- 进程:
进程和线程通信
进程间通信常见方式
- 管道(pipe):
- 一种半双工的通信方式,数据只能单向流动
- 匿名管道:
- 是特殊文件只存在于内存,没有存在于文件系统中,
- 只存在于父子关系的进程间通信,随着进程的消亡而消亡,
- 如shell 命令中的「
|」
- 有名管道:
- 是一种类型为 p 的设备文件
- 允许无亲缘关系进程间的通信
- 信号量(semophore) :
- 数据是无格式的字节流
- 一个计数器,表示的是资源个数,通过 P/V操作
- 常作为一种锁机制,确保任何时刻只能有一个进程访问共享资源
- 实现访问的互斥性,或者进程间的同步(同步指多进程按照特定顺序进行执行)
- 信号(signal):
- 异步通信机制
- 可以在应用进程和内核之间直接交互
- 硬件来源:如键盘 Cltr+C
- 软件来源:如 kill 命令
- 其中
SIGKILL和SIGSTOP是必定停止
- 其中
- 其他信号进程可以按照人为预设:
- 执行默认操作
- 捕捉信号
- 忽略信号
- 消息队列(messagequeue):
- 保存在内核的「消息链表」
- 消息体是可以用户自定义的数据类型,发送接收保持一致
- 每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程,会有较大的延迟
- 共享内存(shared memory):
- 共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。
- 需要注意多进程竞争同个共享资源会造成数据的错乱
- 套接字(socket):
- 实现不同主机的进程间通信
线程间通信常见方式
- 锁机制:包括互斥锁、条件变量、读写锁
- 信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
- 互斥方式:同时只有一个线程访问共享资源(A 和 B 不能同时执行)
- 同步方式:线程间顺序可保证(先执行 A,再执行 B)
- 操作:
- P 操作,将 sem-1,如果 < 0,阻塞,是在进入临界区之前
- V 操作,将 sem+1,如果 >= 0,唤醒一个等待中的进程/县城,是在离开临界区后
- 信号机制(Signal):类似进程间的信号处理
- 全局变量:多线程共享同进程的资源
用户态和内核态
- 用户态(User Mode) :可以直接读取用户程序的数据。执行某些特殊权限如读写磁盘需要向操作系统发起系统调用请求,进入内核态。
- 内核态(Kernel Mode):几乎可以访问任何资源,权限很高,很危险,所以不能交由程序直接操作。
- 如果没有用户态,所有进程共享系统资源,导致资源冲突很大。
- 两者切换成本较高,需要进行一系列的上下文切换和权限检查
- 切换时机:
- 系统调用:用户态进程主动要求切换,如读取磁盘
- 中断:外围设备如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
- 异常:用户态程序发生异常如缺页异常,会切换到内核态处理。
- 中断和异常处理类似,但是中断来自处理器外部,异常是执行当前指令的结果
锁
死锁
死锁只有同时满足以下四个条件才会发生:
- 互斥条件;
- 该资源同时只能由一个线程持有
- 避免方式:
- 把“独占”资源改成可共享副本(copy-on-write)
- CAS/版本号的乐观锁
- 持有并等待条件;
- 持有的资源在想要获取其他资源时不会释放
- 避免方式:超时放弃资源
- 不可剥夺条件;
- 线程只有在使用完资源后才会释放,其他线程无法抢占
- 避免方式:
- 优先级剥夺(高优先级可以抢占)
- 超时放弃
- 环路等待条件;
- 多个线程获取资源的顺序构成了环形链
- 避免方式:多个线程获取资源顺序相同
利用工具排查死锁问题
如果你想排查你的 Java 程序是否死锁,则可以使用 jstack/jconsole 工具,它是 jdk 自带的线程堆栈分析工具。
什么是悲观锁、乐观锁?
悲观锁:先质疑,悲观锁是修改共享数据前,都要先加锁,防止竞争。
- 互斥锁
- 加锁失败,线程会释放CPU
- 线程会睡眠
- 锁被释放后会被唤醒
- 需要切换到内核态,会有两次线程上下文切换,开销较大
- 加锁失败,线程会释放CPU
- 自旋锁
- 加锁失败,线程会忙等待
- 自旋锁基于 CAS 加了 while 或者睡眠 CPU 的操作而产生自旋的效果,加锁失败会忙等待直到拿到锁
- 用户态完成加锁和解锁操作,不会主动产生线程上下文切换,开销较小
- 最好是使用 CPU 提供的
PAUSE指令而不是while循环来实现「忙等待」,降低成本。 - 如果单核CPU,可能导致CPU一直空转(需要强占式调度器)
- 高并发锁竞争记录,通常用于写比较多的情况
- 如果确定被锁住的代码执行时间短,采用自旋锁,否则互斥锁
- 上面是锁的最基本处理方式,读写锁中的写锁基于上面中的一个进行实现
乐观锁:先相信,乐观锁是先修改完共享资源,再验证这段时间内有没有发生冲突
- 如果没有,那么操作完成
- 如果有,操作失败,重试
- 适用于读多写少
- 实现方案:
- 版本号:数据修改次数,修改前获取该值,修改时原子确认该值相等才更新
- 时间戳类似,对于 mysql 可以直接使用 mtime
- CAS(compare and swap):
- 设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID
- CAS(V, E, N):V 变量值,E 预期值,N 新值,V 等于 E 时更新为 N,如果不等相当于其他线程修改过,放弃更新
- CAS(lock, 0, pid) 如果 lock 为 0,设置 lock 为 pid,表示自旋锁的加锁操作
- CAS(lock, pid, 0) 如果 lock 为 pid,设置 lock 为 0,则表示解锁操作。
- ABA 问题:V 经过两次修改回到了 E,但是当前 pid 认为其没有被修改过
- 和版本号、时间戳配合使用
- 由于一般采用自旋重试等待,可能给 CPU 带来很大的开销
- 版本号:数据修改次数,修改前获取该值,修改时原子确认该值相等才更新
在线文档就是使用乐观锁,否则多人无法同时打开文档
公平锁和非公平锁有什么区别?
- 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
- 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
