Java 多线程和并发导论

发布于 2024-07-16 12:40:40 字数 10250 浏览 11 评论 0

线程

什么是线程

线程是什么?

线程指的是一个进程开辟多条路径,充分利用我们的 CPU。

首先操作系统提供了一套 api 和 struct 来对硬件资源进行抽象,那么线程是对什么的抽象?

很简单它是对于 CPU 计算资源的抽象,具体来说:线程的生命周期就是代表一个 CPU 任务的生命周期。

在 Linux 系统中,对于用户创建的进程(线程) 来说,CPU 分配时间片的单位是线程还是进程?

是线程。线程是实际工作的单元,进程只是一个容器,用来管理一个或多个线程。

在一些 CPU 参数里面我们往往能看到诸如 4 核心 8 线程这种参数,就是指 CPU 可以同时执行 8 个线程,即可以同时做 8 个任务

但是我们,在任务管理器中看到的线程却远远大于 8 个,这些线程都是同时执行的吗?

1606272091142

从宏观感觉来看是的,但是从微观来看,不是。

我们现在大部分系统的线程都是抢占式线程,即它只有抢到 时间片 才能运行,如果一个进程创建了很多线程的话,最多也只有 8 个能够处于执行的状态,其余的线程必须等待调度。线程被调度的时候需要进行上下文切换,这个操作是一种额外的开销。线程数量过多的时候,上下文切换产生的额外开销会对系统的效率造成负面影响。

由于切换的非常快所以你在宏观上感受到的是所有线程都在并行,但是实际上我们称之为并发

时间片 (timeslice)又称为“量子(quantum)”或“处理器片(processor slice)”是分时操作系统分配给每个正在运行的 进程 微观上的一段 CPU 时间

实际上 linux 上的线程就是轻量级进程,有些书你会看到都是说调度进程,实际上是一回事

并发?并行?这都什么鬼?

并发就是在 一段 微小的时间内,有多个任务在交叉执行

并行就是在某一 时刻 ,有多个任务在同时执行

简单来说,4 核心 8 线程,意味着同一时刻有 8 个任务在同时执行,但是如果我们看 5ms 的情况就可能有 50 个任务在交错执行

思考一个简单的情况:整个系统只有我们的程序在跑,此时系统是两核心两线程,那么当我们开启一个线程 A 和线程 B 时,操作系统帮我们调度到 core 0 和 core 1 来执行,此时 A B 就是并行了。然后我们再开启一个线程 C,A,B 谁先用完时间片谁被操作系统暂停(抢断),切换到 C 来执行,此时三个线程关系就变成并发了

如果你还是理解不了,那么你就理解成多线程就是并发就行了

线程生命周期

为了方便理解,我们假设一个线程要做的事情是这样的,先创建出来,做几个算术题,读一段文件,然后再做一段长时间的运算题,最后结束

1,首先 os 肯定要记录需要执行的线程对吧,那么就需要一个待调度到 CPU 上的队列存这些线程

2,读文件,这种 IO 非常慢,CPU 肯定是不愿意等待完成的,一般是给 DMA 发消息,DMA 完成后再通知的,既然不愿意等待,那么应该是暂停执行这 个任务,切到调度其他任务对吧,而 IO 结束我们又需要继续执行之前的任务,那么就需要一个标记位来指示状态,等完成任务的中断一来我们再丢到 1 中提到的队 列中就行了

3,一个线程运行了很长是不是会影响其他线程的执行,比如说你也不想打开了网站看视频的同时不能用 QQ 吧,所以这个时候就需要操作系统抢断当前线程把它丢到 1 的队列中,切到另外一个线程来利用 CPU 资源

综上 线程启动时是就绪,操作系统调度给他时间片开始运行此时就变成运行态了,遇到 IO 这种阻塞 api 变成阻塞态,等待完成后再变成就绪态,然后再等调度,这样就完成了闭环

运行太长也会变成就绪态等待就绪

img

Java 的线程

不同语言对线程库的实现并不一致

在操作系统中,有两种不同的方法提供线程支持:用户态的用户线程,或内核态的内核线程。

1:N 模型:实际上只有一个线程在运行,其中用户线程在内核之上支持,并在用户层通过线程库来实现。不需要用户态/核心态切换,速度快。操作系统内 核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时 间相对减少。

N:M 模型:有 N 个内核线程驱动 M 个用户态线程,N 远大于 M,比如说 go 的 goroutine 支持个位数的内核线程驱动上万的用户态线程(java 也会有的,project loom)

内核线程由操作系统直接支持。由操作系统内核创建、调度和管理。内核维护进程及线程的上下文信息以及线程切换。一个内核线程由于 I/O 操作而阻塞,不会影响其它线程的运行。

而目前 Java 对线程的实现则是与内核线程 1:1 的实现,即你可以理解为它每创建一个 Java 线程都对应了一个实际的内核线程(2022 年 4 月 24 日)

创建线程

创建线程一共有三种方式,我们这里只讲两种,剩下一种后面讲

  • 使用 Thread.currentThread() 获取当前运行线程

继承 Thread 类

public static class CustomerThread extends Thread{
        @Override
        public void run() {
            System.out.println("启动一个线程:"+Thread.currentThread().getName());
        }
    }

实现 Runnable 接口

public static class CustomerRunnable implements Runnable{
        @Override
        public void run() {
            System.out.println("启动一个线程:"+Thread.currentThread().getName());
        }
    }

启动线程

请切记:启动线程的方法是**start()**而不是 run()

//Thread 子类就直接 start() 就启动了
        new LearnThread.CustomerThread().start();
        //Runnable 子类需要一个 Thread 实例
        new Thread(new LearnThread.CustomerRunnable()).start();
        System.out.println("我是主线程"+Thread.currentThread());

输出

启动一个线程:Thread-1
启动一个线程:Thread-0
我是主线程 Thread[main,5,main]

请同学们自行尝试如果使用 run() 方法会发生什么:

实际上就是在当前线程上运行 run() 的方法,就是调用了一个普通的方法

start() 后发生了什么

其中的 native 方法就是会调用系统底层的接口启动线程。在新开的线程中执行 run()

 public synchronized void start() {
        /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */
        if (threadStatus != 0)
            throw new IllegalThreadStateException();

        /* Notify the group that this thread is about to be started
         * so that it can be added to the group's list of threads
         * and the group's unstarted count can be decremented. */
        group.add(this);

        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */
            }
        }
    }

    private native void start0();

run() 方法,就是在其调用线程中直接调用内部那个 Runnable 实现类的 run 罢了,

@Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }
  • start() 中的
if (threadStatus != 0)
            throw new IllegalThreadStateException();

确保了不会启动两次同一个线程

具体 native 发生了什么请参考这个 深入虚拟机探索 Thread start_源码_4ye_InfoQ 写作社区

线程是怎么调度切换的?*

CPU 是怎么执行程序的?

注意:下面的说明只是一种简化的情况,具体情况你需要去看对应 CPU 的手册

我们只谈论那些常见的指令和寄存器,也不细节讨论 os 的实现,大体是会以 linux 的实现为例子

不讨论具体是怎么实现的 有兴趣的可以去学习 CS:APP3e, Bryant and O'Hallaron (cmu.edu)

有时候为了方便描述 我会以代码行数作为 PC(程序计数器)的值 而不是对应的指令地址

指令

首先 CPU 得知道要执行那个指令对吧,这里就需要一个寄存器 PC,程序计数器用来指定下一条指令位置

而运算也需要数据从内存加载到 CPU 内部,此时就需要其他寄存器储存变量/参数

而我们的控制流语句实际上就是通过改变 PC 的值来控制跳转的,通过不同的计算的结果来反映到 PC 上面 再指示 CPU 该向哪里走

数据

现在我们弄明白了指令,那么作为图灵机的另外一半——数据又是怎么来的呢?

1,数据在哪?

我们简单地把内存分成堆,栈。

堆用于存放各种共享的变量,这是同一进程下面各个线程共享的内存区域 下文的 p 对应的 4 个字节空间就在这里

栈用于存放”线程私有“的变量,比如说下面的 a 就是分配到各个线程私有的栈上面的

int fun(){
    int a = 10;
    int* p = (int*)mallock(sizeof(int));
}

img

这两种分类只是方便理解操作而已,CPU 对这些一无所知,它只知道去哪个地址拿 n 个长度的数据,CPU 不在乎在哪取。因为堆地址和栈地址都在同一个内存空间内,所以只要你在保证地址有效的情况下,另外一个线程甚至可以修改别的线程栈空间。

我们有这样一段求阶乘的代码:

00  int fact(int n) {
01    int result;
02    if (n <= 1)
03      result = 1;
04    else
05      result = n * fact(n - 1);
06    return result;
07  }

img

当我们执行完毕顶层的函数时,会让 SP(栈顶指针)下移,若此时我们再去访问 n-2 栈的 result 对应空间,这个值是是否还保持原样?这个不好 说,这是个未定义行为,此时这个变量地址不再具有意义。但是如果我们此时这个线程停下不执行了,而把这个变量地址传递给另外一个线程,后者是可以修改的 (只是说明可行,但是不代表推荐你这么做,这样把栈变量泄露出去的行为很危险)

总结

总结一下就是,内存状态+寄存器状态代表了当前的程序运行状态,这个可以称之为上下文 context

程序快照

如果我们可以给内存和 CPU 拍一个照片,记录下当前的状态,是不是就可以根据这个状态,随时随地恢复当前的程序执行状态?

堆内存 其实不需要管,毕竟是大家线程共享的,我们恢复线程执行只要恢复它私有的部分就行了

也就是说内存这边需要恢复栈内存,因为栈的实现要求私有,故不存在栈和栈堆叠的问题,又因为栈要求是连续的空间只要记录一下栈顶指针和栈底指针就好了,这两个分别是 ESP 和 EBP 寄存器的任务

综上 我们要记录当前的快照只需要把全部的寄存器拷贝一下,丢到堆上面 然后恢复的时候再读取到寄存器里面就行了。

可以了解一下 glibc 提供的 ucontext 函数簇

#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>

int main(int argc, const char *argv[]){
0	ucontext_t context;
1	
2	getcontext(&context); //
3	puts("Hello world");
4	sleep(1);
5	setcontext(&context);
	return 0;
}

这会产生无限循环,因为上下文包含程序计数器。这个 context 实际上是 PC=3 的快照 因此每次调用到 5 的时候就会跳转到 3 再向下执行

实际上很多协程库也是这样实现的,不过是模拟线程调度,不主动让线程阻塞而是利用当前的计算资源驱动不同的协程

简单来说 线程切换的核心操作就是这些——切换上下文

为什么不能开启大量的线程?

因为实时操作系统要尽可能应对不同的任务,它的调度策略是趋向于公平的,所以它会尽可能调度所有的就绪线程。大量的线程就需要更多的调度操作,若大量的线程都是短期任务,那么保存上下文,恢复上下文的代价相比之下就会变得难以接受

线程同时也有一些自己私有的数据,比如说栈,这些都会消耗我们的内存,大量的线程也会对线程带来压力

进程/线程上下文切换做了什么

那么上下文切换的时候,CPU 的开销都具体有哪些呢?开销分成两种,一种是直接开销、一种是间接开销。

直接开销就是在切换时,cpu 必须做的事情,包括:

  1. 切换页表全局目录
  2. 切换内核态堆栈
  3. 切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)
    • ip(instruction pointer):指向当前执行指令的下一条指令
    • bp(base pointer): 用于存放执行中的函数对应的栈帧的栈底地址
    • sp(stack poinger): 用于存放执行中的函数对应的栈帧的栈顶地址
    • cr3:页目录基址寄存器,保存页目录表的物理地址
    • ......
  4. 刷新 TLB
  5. 系统调度器的代码执行

间接开销主要指的是虽然切换到一个新进程后,由于各种缓存并不热,速度运行会慢一些。如果进程始终都在一个 CPU 上调度还好一些,如果跨 CPU 的 话,之前热起来的 TLB、L1、L2、L3 因为运行的进程已经变了,所以以局部性原理 cache 起来的代码、数据也都没有用了,导致新进程穿透到内存的 IO 会变多。

想了解更详细操作过程的同学请参考《深入理解 Linux 内核》中的第三章和第九章。

在 Linux 下其实本并没有线程,只是为了迎合开发者口味,搞了个轻量级进程出来就叫做了线程。轻量级进程和进程一样,都有自己独立的 task_struct 进程描述符,也都有自己独立的 pid。从操作系统视角看,调度上和进程没有什么区别,都是在等待队列的双向链表里选择一个 task_struct 切到运行态而已。只不过轻量级进程和普通进程的区别是可以共享同一内存地址空间、代码段、全局变量、同一打开文件集合而已。

我们该阻塞线程吗?

上面提到了阻塞会导致线程的切换,而切换又代价较高,那么是不是不让线程阻塞最好呢?

其实不是,我们要避免的是阻塞后恢复没多久又需要阻塞了 如下图情况 这种情况下面属于是花费了很大代价

对于 CPU 来说很多算力就浪费在切换上面了(这种情况非常常见,web 服务就是这种情况)

void block(){
    read(fd,buffer);
    int a = 1+1;
    read(fd,buffer);
}

但是如果我们等待一个很长时间的 IO 时,这个时候就可以放心去阻塞了

操作系统是怎么实现抢占的?

我们以及讲清楚了如何切换上下文,每一个线程的切换就是把旧有的上下文保存,新的上下文恢复

那么它是怎么知道线程该切换的,阻塞的情况是主动让出,那么若我的程序一直不阻塞呢?它是怎么知道我的时间片用完的?

都是简化模型

因为我确实能力有限

很简单 CPU 会有个管脚用于接收信号。学过微机和数电的同学应该知道边缘触发和中断,外界的一个信号源每个周期会将信号从 0 变成 1,或者反过来(矩形波)。那么这个信号源就会输入到 CPU 的管脚里面,执行对应的中断程序。

举个例子,就像是你在做作业,你同学给你了一巴掌,你就开始处理这个“巴掌事件”,这个巴掌就是中断

那么计时就是通过这种周期性的时钟中断实现的。它作为 OS 的心跳,它可以驱动调度器运转,并且统计相关的运行信息(比如 utimestime 的计算)。

这个中断的优先级非常非常高 ,因此操作系统必须立刻去处理,此时就会去执行中断处理程序的代码,来计算各类指标,调度器就可以知道哪个线程到时间了该进行调度了

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

0 文章
0 评论
22 人气
更多

推荐作者

謌踐踏愛綪

文章 0 评论 0

开始看清了

文章 0 评论 0

高速公鹿

文章 0 评论 0

alipaysp_PLnULTzf66

文章 0 评论 0

热情消退

文章 0 评论 0

白色月光

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文