并发概述

154 阅读12分钟

进程

进程概述

进程(Process)是一个正在执行的程序的实例,是操作系统分配资源(如内存、CPU时间等)的基本单位。每个进程都有自己独立的内存空间、堆栈和数据段。

内存空间:进程是相互独立的,不共享内存空间。进程间相互隔离,一个进程的崩溃不会直接影响其他进程。

通信方式 :由于进程间的独立性,进程间通信(IPC)需要使用操作系统提供的机制,比如使用管道、消息队列、共享内存、信号量等机制。

开销:进程的创建、切换、销毁需要较多的系统资源和时间。

在Java中,进程可以通过Runtime.getRuntime().exec()ProcessBuilder类来启动外部进程。

 Process process = Runtime.getRuntime().exec("notepad.exe");
 process.waitFor(); // 等待进程结束

进程执行过程

简单介绍一下进程在计算机内部执行的过程:

进程的创建:程序是静态的代码和数据的集合,而进程是程序的动态执行实例。当你启动一个程序(如运行一个应用程序),操作系统会创建一个对应的进程。

操作系统为每个进程分配一个数据结构,称为进程控制块(PCB)。PCB包含进程的所有重要信息,如进程ID(PID)、程序计数器(指示下一条要执行的指令)、CPU寄存器、内存管理信息、I/O状态信息等。

加载程序到内存:操作系统将程序的代码和数据加载到内存中,为进程分配所需的内存空间。

进程的内存布局通常包括:代码段(包含程序的可执行代码)、数据段(包含全局变量和静态变量)、堆区(用于动态内存分配)、栈区(用于存储函数调用的参数、局部变量和返回地址)...

进程调度:在内存中加载并准备执行的进程被放入就绪队列。操作系统通过调度程序在就绪队列中选择下一个要执行的进程,并将其调度到CPU上运行。

调度程序决定哪个进程应在何时获得CPU时间,常见的调度算法包括:先来先服务(按进程到达的顺序执行)、最短作业优先(选择估计执行时间最短的进程优先执行)、时间片轮转(每个进程轮流获得固定的时间片来执行)、优先级调度(根据进程的优先级分配CPU时间)...

CPU执行指令:当调度程序决定执行这个进程时,操作系统会将该进程的上下文(包括寄存器内容、程序计数器等)加载到CPU中,开始执行进程的代码。这一阶段,进程不断从内存中取指令、执行指令,并根据需要访问或修改内存中的数据。

如果一个进程在运行时被中断(例如时间片结束或发生I/O请求),操作系统会保存当前进程的上下文(即PCB中的CPU寄存器、程序计数器等),然后将CPU分配给另一个进程。这一过程称为上下文切换。

进程的终止:当进程执行完毕或被强制终止时,操作系统会回收该进程占用的所有内存资源,并清理PCB和其他相关的数据结构。

在多核CPU系统中,操作系统可以将多个进程分配给不同的CPU核心,真正实现进程的并行执行。这种情况下,每个核心可以独立运行一个进程,不需要频繁的上下文切换,从而提高系统的性能。

线程

线程概述

线程(Thread)是进程内的一个执行路径,是CPU调度和分派的基本单位。一个进程可以包含多个线程,这些线程共享进程的资源(如内存、文件句柄等)。

内存空间:同一进程内的线程共享相同的内存空间,多个线程可以访问相同的变量和资源

通信方式:线程间通信直接使用共享内存,因此线程间通信更为简单,速度也更快。

开销:线程的创建、切换和销毁比进程更快,开销更小。

线程执行过程

简单介绍一下线程在计算机内部执行的过程

线程的创建:与进程类似,每个线程有自己的线程控制块(TCB),其中包含线程的状态信息,如线程ID、寄存器状态、栈指针、程序计数器等。

一个进程可以包含多个线程,所有线程共享进程的资源(如内存地址空间、全局变量、文件句柄),但每个线程有自己独立的程序计数器、寄存器、栈空间。

线程调度:与进程一样,线程也被放入操作系统的就绪队列,等待调度程序将其分配给CPU执行。

线程的执行:当线程被调度到CPU上时,CPU开始执行该线程的指令。线程的指令执行与进程中的指令执行类似,包含取指令、解码指令、执行指令和存储结果等步骤。

当操作系统决定将CPU从一个线程切换到另一个线程时,需要执行上下文切换。上下文切换包括保存当前线程的寄存器状态、程序计数器,并加载下一个线程的状态。这一过程与进程上下文切换类似,但由于线程共享进程的资源,切换的开销通常比进程切换要小。

线程的终止:当线程完成了它的任务或执行的代码块(如run()方法)结束时,线程将自动终止。操作系统会回收该线程占用的资源,包括它的栈内存和TCB。

Java中,线程可以通过Thread类或实现Runnable接口来创建,这也是本专栏的重点。

并发与并行

并发:在单核CPU上,多个线程或进程通过快速切换的方式,使得它们看起来像是同时执行。这种执行方式称为并发。

并行:在多核CPU上,不同的进程或线程可以在多个核心上同时执行。这种执行方式称为并行。

多线程和多进程编程的主要目标之一是提高CPU的利用率。通过让多个进程和线程同时运行,尽可能减少CPU的空闲时间,从而提升系统的整体性能。

在计算密集型任务中,多线程可以充分利用多核CPU的能力,显著提高执行速度;在I/O密集型任务中,线程可以在等待I/O操作完成时让出CPU,其他线程可以利用这段时间进行计算,从而提高程序的响应能力。

总而言之,线程是进程中的一个执行路径,也称为“轻量级进程”。因为线程共享进程的内存和资源,所以大大减少了创建和切换的开销,但也带来了资源竞争和同步问题。因此,设计高效且安全的多线程程序需要仔细考虑线程的同步和通信策略。

并发问题

并发问题说难不难,说简单不简单,并发问题的本质就是多个线程同时修改了同一块共享资源

那这个“共享资源”是什么,“同时”又是什么意思呢?

共享资源

根据上面讲的“线程”的特性,很自然的就会想到,这个共享资源就是内存中的数据,比如类的静态变量、对象的成员变量、集合或数据结构...

对于 Java 来说,更具体的对于 JVM 来说,存放对象信息的堆内存(Heap Memory)是共享的,存放类信息的元空间(Metaspace)也是共享的,而记录线程所执行的字节码指令地址的程序计数器(Program Counter, PC)、记录方法调用的虚拟机栈(Java Virtual Machine Stack)是线程独占的资源。

这解释了为什么方法内部的局部变量在多线程环境下不会引发并发问题。局部变量存储在线程独占的虚拟机栈中,而对象的成员变量存储在线程共享的堆内存中。

其实不止内存,文件系统、数据库、网络套接字、设备I/O...也都是可以被线程共享的资源,在修改他们的时候都需要注意多线程问题。

从 i++ 看“同时”

知道了共享资源,那“同时”该怎么理解呢?比如两个线程同时执行 i++,这个同时,具体到CPU级别会发生什么情况呢?

在具体分析之前,这里需要明确一个概念,单条CPU指令的执行过程中,操作系统不会进行任务切换,也就是说CPU 能够保证原子操作的是在 CPU指令级别。

但对于像 i++ 这样的高级语言层面的单条语句,CPU是不保证不会发生线程切换的。看似线程A执行完 i++ 后,内存中的值被加1后,才会发生线程切换,线程B看到的值是线程A修改完的,很完美。

但其实这一个 i++ 语句对应 CPU 三条指令:

# 第一步:读取变量i 的内存数据到 CPU 的一个寄存器中 
mov eax, DWORD PTR i[rip] 
# 第二步:累加寄存器进行自增 
add eax, 1 
# 第三步:将寄存器的值移动到 变量i 的内存地址中 
mov DWORD PTR i[rip], eax

所以线程A在从内存中将变量 i 加载到寄存器,进行自增后,是有可能发生线程切换的,如果这时线程切换,线程B也从内存中将变量 i 加载到寄存器,注意,此时线程B读取的值可能与线程A刚刚读取的值相同,因为线程A还没有完成更新。

要解决这个问题,很自然的就能想到保证 i++ 操作的原子性即可,让CUP执行该语句的时候,不要发生线程切换,至少不能切换到另一个执行 i++ 的线程,要就是说保证对 i++ 操作的互斥性即可。

原子性是一种更细粒度的概念,它关注的是单个操作或一组操作在多线程环境下不可分割的特性。

互斥性则是一种更粗粒度的概念,它确保了在一段时间内只有一个线程可以访问共享资源。

但这样就够了吗?现代处理器通常使用多级缓存(L1、L2、L3)来提高内存访问速度。不同的线程可能运行在不同的CPU核心上,并且每个核心有自己的缓存。如果一个线程修改了某个共享变量的值,这个修改可能仅存在于该核心的缓存中,其他线程很可能无法立即看到这个更新。

也就是说“保证i++互斥性”还不够,还要保证执行 i++ 语句的线程,对变量的修改是对其他线程可见。

作个汇总,要解决线程并发问题,需要:

互斥性:保证修改共享资源的线程同一时刻只能在一个CPU上执行,它不执行完,不能切换到其他对该共享资源修改的线程上去;

内存可见性:保证修改共享资源的线程,在执行完,修改的内容要刷新到内存上去,后续其他对该共享资源修改的线程也不要读自己缓存中的数据了,要重新从内存中读。

当然,上述举例用的 i++ 可以是任何语句,可以是任意语句的集合。

下面看一个在“单例模式”中举的双重检查例子:

从懒汉单例看“互斥性”

废话就不讲了,直接上代码:

public class LazyDoubleCheckSingleton {
    private static LazyDoubleCheckSingleton instance = null;
    private LazyDoubleCheckSingleton(){}
    public static LazyDoubleCheckSingleton getInstance(){
        if(instance == null){
            synchronized (LazyDoubleCheckSingleton.class){ // 此处为类级别的锁
                if(instance == null){
                    instance = new LazyDoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

上述代码有没有问题?

synchronized 关键字可以让 LazyDoubleCheckSingleton 对象的创建过程互斥化,也就是说在某个线程创建期间,其他线程无法执行该创建语句,符合互斥性。

那符合内存可见性吗?也符合!JVM内存模型规定,对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。也就是说后续线程进入到 synchronized 里面时,可以看到内存中已经存在这个对象了,就避免了二次创建。

互斥性和内存可见性都符合,那就没有并发问题喽,其实不然,并发编程除了考虑对共享执行修改的互斥,也要注意对共享变量读取的互斥。

上述 synchronized 关键字虽然可以确保对象创建的互斥性和内存可见性,但不能避免其他读线程在对象创建期间读取该对象。

是不是有点数据库读写锁的感觉了。

比如说上述代码通过 new关键字创建对象,我们想当然以为的步骤是 ① 分配内存给这个对象;② 初始化对象;③ 设置 instance 变量指向刚分配的内存地址。

如果 new 对象按照这个顺序执行,那是什么问题也没有,但编译器和处理器为了提高性能,可能会对指令进行重排序,如果上述步骤发生了指令重排,这个顺序可能是 ① ③ ②,会在对象初始化之前就将刚分配的内存地址赋值给 instance 变量。

如果线程A在执行完第 ① ③ 步后,执行步骤 ② 前,发生线程切换,虽然切换的线程受限于修改互斥,不能执行创建语句,但可以执行 getInstance 方法其他语句呀,比如切换到刚执行 getInstance 方法的 B线程,发现 instance 变量已经不为 null(线程A已经执行完 ③ 步了),从而线程B获取到的对象并不是初始化完的对象,这就会导致线程B使用该对象时,程序执行出错。

总结

并发编程看似复杂,其实也不简单,但只要抓住两点——修改互斥性内存可见性,就能够有效地处理大部分的并发问题。

如果再能考虑到读写互斥性,那还不得一飞冲天呀!