进程和线程有什么区别?一个常常被问到的面试题
我们在实际的开发过程中,经常打交道的就是线程,而进程呢,通常就是我们整个运行的程序。对于他们两个来说其实并不陌生,你要让我说出个一二三也可以讲,但可能也都是从使用的角度,而今天我们就从 操作系统 的角度来重新认识一下他们两个(从内核的角度看进程和线程长什么样)。
大纲:
你所需要把握的重点是:结构、创建和调度。这些对于以后的开发或是问题的解决都是有着密切联系的。
首先让我们从实际角度来直观感受什么是进程,通过 ps -ef
命令可以查看当前进程的相关情况
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 2021 ? 00:38:48 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
root 2 0 0 2021 ? 00:00:05 [kthreadd]
root 4 2 0 2021 ? 00:00:00 [kworker/0:0H]
root 6 2 0 2021 ? 00:04:01 [ksoftirqd/0]
root 7 2 0 2021 ? 00:00:46 [migration/0]
root 8 2 0 2021 ? 00:00:00 [rcu_bh]
.....
.....
.....
root 379 1 0 2021 ? 00:13:31 /usr/lib/systemd/systemd-journald
root 395 1 0 2021 ? 00:00:00 /usr/sbin/lvmetad -f
root 407 1 0 2021 ? 00:00:00 /usr/lib/systemd/systemd-udevd
root 480 2 0 2021 ? 00:00:00 [nfit]
root 625 1 0 2021 ? 00:03:56 /sbin/auditd
dbus 650 1 0 2021 ? 00:18:33 /usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation
root 667 1 0 2021 ? 00:08:00 /usr/lib/systemd/systemd-logind
libstor+ 668 1 0 2021 ? 00:00:28 /usr/bin/lsmd -d
polkitd 670 1 0 2021 ? 00:06:22 /usr/lib/polkit-1/polkitd --no-debug
root 672 1 0 2021 ? 00:00:00 /usr/sbin/acpid
.....
.....
.....
root 27701 1334 0 11:36 ? 00:00:00 sshd: root@pts/0
root 27703 27701 0 11:36 pts/0 00:00:00 -bash
root 28030 27703 0 11:38 pts/0 00:00:00 ps -ef
可以看到有非常多的进程在运行,可以简单的看一下:
[]
的进程是内核态的进程,PPID 也就是父进程是 2 号进程 kthreadd
ps
的进程就是 28030, 它的父进程就是 27703 也就是 bash,它的父进程的父进程就是 27701 也就是我们的 ssh我们可以根据不同的角度给进程下一个定义:
既然我们是学习操作系统,那么自然就应该从操作系统的角度去分析进程到底里面有些什么东西。在操作系统中,每个进程都需要一个数据结构来保存相关信息,这个数据结构称为 进程控制块 (PCB Process Control Block)。
在 Linux 中 PCB 被命名为 task_struct
这个结构非常复杂,里面有着很多进程在运行过程中所需要的信息,整体结构图如下。
首先肯定需要一个唯一标识,去标识进程。
操作系统需要去控制进程的状态,肯定需要一些手段,而信号就是手段,进程需要处理操作系统发给它的信号从而做出相对应的反应。所以进程中有一些特定的结构来接收处理对应的信号。
举例来说,我们常常会使用 kill
命令 “杀进程” ,这个操作就是给进程发送了一个信号,让它关闭。
当然我们需要一个状态标识,去表示当前进程的状态,是已经停止了(TASK_STOPPED)?还是正在 运行等待分配 CPU 来执行(TASK_RUNNING),还是已经睡着了(TASK_INTERRUPTIBLE)。
当有很多进程都在运行的时候,操作系统肯定需要管理这些进程的运行,毕竟 CPU 只有一个,那么谁先运行,谁后运行就很重要了。
那么进程中就需要一些字段来保存如:优先级,调度策略,可以使用哪些 CPU 等等的相关信息了。
mm_struct
就是用来表示它的fs_struct
这里我就将进程的几个重要结构罗列了一下,后面还会详细展开,你只需要现在有一个印象就可以了。
其实源码中的 task_struct
字段众多,如果你还想详细了解,我将源码的链接放在了文章的最后。
我们通常可以通过 top
命令来查看当前系统进程的状态,其中有个 S 一列就代表状态
从这张图上我们可以非常清楚的了解到进程状态的改变,其中有几个要点:
进程状态的划分在网上我找到了很多划分方式,我这里是借用了 top 命令中的几种状态来进行划分的,并不绝对
进程的创建和线程的创建在本章中是重点,也和我们的开发工作息息相关
创建进程是使用 fork
方法来完成的,所以我们需要搞清楚它做了什么事情
fs_struct
sighand_struct
mm_struct
wake_up_new_task
所以对于进程的创建可以总结为:创建结构,复制老爸,唤醒儿子glibc 中有一个 pthread_create()
函数,来创建线程
比如原来的文件系统相关的结构 fs_struct 应该被复制一份,结果就变成了 fs_struct 的计数器+1。
我们可以从下面这幅图中对比进程和线程的创建结果,你可以简单的理解为:一个是复制,一个是引用。当然复制的成本大,引用创建的成本小。
所以从内核的角度看,线程和进程都是一个 task_struct 结构,从表面看好像进程和线程长得一样,但如果内核真的想要认出这个是线程还是进程还是有办法的,可以通过 pid 和 tgid,同一个进程中的所有线程有相同的 tgid
所谓调度,其实就是有很多事情,这些事情有紧急的,有不紧急的,有花时间长的,有花时间短的,如何合理的使用已有的资源让这些事情尽可能合理又迅速的完成,这就是调度所需要做的。
所以吧,操作系统也挺难的,需要我们编写很多调度的策略,根据具体的策略进行调度进程,从而更好的完成任务。
实时调度策略是针对实时进程的调度策略,这些进程的优先级都非常高,对实时性要求高,大家都是紧急的,所以目标是看优先级
普通调度就是调度普通的进程,大家都是普通的,所以目标是保证公平
CFS(Completely Fair Scheduling) 这算法听上去很公平的样子,其实说起来也很简单,就是我们常说的,CPU 会提供一个时钟,时钟的每一个间隔就会为一个进程安排一个时间 vruntime
, 用于记录每个进程运行的时间,vruntime
运行过就会变大,而没有运行过则不会变,所以当那些没有运行过的进程不公平了,就会优先运行它,来补上时间。
这里设计的关键在于,相当于在动态的调整进程的优先级。
当我们在操作外部设备的使用,往往需要主动让出 CPU 的资源,让操作系统把我们调度走,这样的调度就是主动调度。比如:网络、存储等。方式也很简单主动调用 schedule
方法就可以了。
当前抢占的时机很关键,不能你才读了命令,执行到一半,“卡” 就给你停了。
preempt_disable
方法关闭抢占,而后面当调用 preempt_enable
方法打开抢占的时候,此时就也是一个不错的时机当前 A 进程正在执行,现在要调度到 B 进程开始执行,那么我们能想到的就是需要将 A 进程当前运行的状态,也就是上下文要保存起来,以便下次 A 进程回来执行的时候知道之前运行到哪里了。同时上下文的切换又分为用户态进程空间的切换和内核态的切换。简单的说,进程的切换是有开销的,且开销比较大。
线程的创建最终和进程的创建使用的都是 fock 方法,但线程的创建不需要复制相关结构,直接使用的是进程的相关结构的引用,故线程确实更加轻量一些,创建所需要消耗的资源也相对较少。
所以,很多语言如:golang,提供了协程的概念,为什么要提供它呢,为的其实就是再抽象一层,让创建worker 能更加更加轻一点,相对线程就所需资源就更加少,调度和切换起来也就更加省力。
其实关于调度的相关算法我们在很多地方都能用到,调度的关键就是能合理分配资源,这样的方法可以应用在 缓存设计、消息消费或是负载均衡等。
在实际生活场景中,很多时候其实最终我们都能将一些场景抽象为一个东西状态的变化,只要能将它的状态变化画出来,作一个状态图,那么很多设计就能更加清晰。这样的状态图在一些软件设计中尤为重要,能让人更加容易理解。