「图文结合」Linux 进程、线程、文件描述符的底层原理

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了「图文结合」Linux 进程、线程、文件描述符的底层原理相关的知识,希望对你有一定的参考价值。

参考技术A

开发十年经验总结,阿里架构师的手写Spring boot原理实践文档

阿里架构师的这份:Redis核心原理与应用实践,带你手撕Redis

Tomcat结构原理详解

说到进程,恐怕面试中最常见的问题就是线程和进程的关系了,那么先说一下答案: 在 Linux 系统中,进程和线程几乎没有区别

Linux 中的进程其实就是一个数据结构,顺带可以理解文件描述符、重定向、管道命令的底层工作原理,最后我们从操作系统的角度看看为什么说线程和进程基本没有区别。

首先,抽象地来说,我们的计算机就是这个东西:

这个大的矩形表示计算机的 内存空间 ,其中的小矩形代表 进程 ,左下角的圆形表示 磁盘 ,右下角的图形表示一些 输入输出设备 ,比如鼠标键盘显示器等等。另外,注意到内存空间被划分为了两块,上半部分表示 用户空间 ,下半部分表示 内核空间

用户空间装着用户进程需要使用的资源,比如你在程序代码里开一个数组,这个数组肯定存在用户空间;内核空间存放内核进程需要加载的系统资源,这一些资源一般是不允许用户访问的。但是注意有的用户进程会共享一些内核空间的资源,比如一些动态链接库等等。

我们用 C 语言写一个 hello 程序,编译后得到一个可执行文件,在命令行运行就可以打印出一句 hello world,然后程序退出。在操作系统层面,就是新建了一个进程,这个进程将我们编译出来的可执行文件读入内存空间,然后执行,最后退出。

你编译好的那个可执行程序只是一个文件,不是进程,可执行文件必须要载入内存,包装成一个进程才能真正跑起来。进程是要依靠操作系统创建的,每个进程都有它的固有属性,比如进程号(PID)、进程状态、打开的文件等等,进程创建好之后,读入你的程序,你的程序才被系统执行。

那么,操作系统是如何创建进程的呢? 对于操作系统,进程就是一个数据结构 ,我们直接来看 Linux 的源码:

task_struct 就是 Linux 内核对于一个进程的描述,也可以称为「进程描述符」。源码比较复杂,我这里就截取了一小部分比较常见的。

我们主要聊聊 mm 指针和 files 指针。 mm 指向的是进程的虚拟内存,也就是载入资源和可执行文件的地方; files 指针指向一个数组,这个数组里装着所有该进程打开的文件的指针。

先说 files ,它是一个文件指针数组。一般来说,一个进程会从 files[0] 读取输入,将输出写入 files[1] ,将错误信息写入 files[2] 。

举个例子,以我们的角度 C 语言的 printf 函数是向命令行打印字符,但是从进程的角度来看,就是向 files[1] 写入数据;同理, scanf 函数就是进程试图从 files[0] 这个文件中读取数据。

每个进程被创建时, files 的前三位被填入默认值,分别指向标准输入流、标准输出流、标准错误流。我们常说的「文件描述符」就是指这个文件指针数组的索引 ,所以程序的文件描述符默认情况下 0 是输入,1 是输出,2 是错误。

我们可以重新画一幅图:

对于一般的计算机,输入流是键盘,输出流是显示器,错误流也是显示器,所以现在这个进程和内核连了三根线。因为硬件都是由内核管理的,我们的进程需要通过「系统调用」让内核进程访问硬件资源。

PS:不要忘了,Linux 中一切都被抽象成文件,设备也是文件,可以进行读和写。

如果我们写的程序需要其他资源,比如打开一个文件进行读写,这也很简单,进行系统调用,让内核把文件打开,这个文件就会被放到 files 的第 4 个位置,对应文件描述符 3:

明白了这个原理, 输入重定向 就很好理解了,程序想读取数据的时候就会去 files[0] 读取,所以我们只要把 files[0] 指向一个文件,那么程序就会从这个文件中读取数据,而不是从键盘:

同理, 输出重定向 就是把 files[1] 指向一个文件,那么程序的输出就不会写入到显示器,而是写入到这个文件中:

错误重定向也是一样的,就不再赘述。

管道符其实也是异曲同工,把一个进程的输出流和另一个进程的输入流接起一条「管道」,数据就在其中传递,不得不说这种设计思想真的很巧妙:

到这里,你可能也看出「Linux 中一切皆文件」设计思路的高明了,不管是设备、另一个进程、socket 套接字还是真正的文件,全部都可以读写,统一装进一个简单的 files 数组,进程通过简单的文件描述符访问相应资源,具体细节交于操作系统,有效解耦,优美高效。

首先要明确的是,多进程和多线程都是并发,都可以提高处理器的利用效率,所以现在的关键是,多线程和多进程有啥区别。

为什么说 Linux 中线程和进程基本没有区别呢,因为从 Linux 内核的角度来看,并没有把线程和进程区别对待。

我们知道系统调用 fork() 可以新建一个子进程,函数 pthread() 可以新建一个线程。 但无论线程还是进程,都是用 task_struct 结构表示的,唯一的区别就是共享的数据区域不同 。

换句话说,线程看起来跟进程没有区别,只是线程的某些数据区域和其父进程是共享的,而子进程是拷贝副本,而不是共享。就比如说, mm 结构和 files 结构在线程中都是共享的,我画两张图你就明白了:

所以说,我们的多线程程序要利用锁机制,避免多个线程同时往同一区域写入数据,否则可能造成数据错乱。

那么你可能问, 既然进程和线程差不多,而且多进程数据不共享,即不存在数据错乱的问题,为什么多线程的使用比多进程普遍得多呢 ?

因为现实中数据共享的并发更普遍呀,比如十个人同时从一个账户取十元,我们希望的是这个共享账户的余额正确减少一百元,而不是希望每人获得一个账户的拷贝,每个拷贝账户减少十元。

当然,必须要说明的是, 只有 Linux 系统将线程看做共享数据的进程 ,不对其做特殊看待 ,其他的很多操作系统是对线程和进程区别对待的,线程有其特有的数据结构,我个人认为不如 Linux 的这种设计简洁,增加了系统的复杂度。

在 Linux 中新建线程和进程的效率都是很高的,对于新建进程时内存区域拷贝的问题,Linux 采用了 copy-on-write 的策略优化,也就是并不真正复制父进程的内存空间,而是等到需要写操作时才去复制。 所以 Linux 中新建进程和新建线程都是很迅速的

学习笔记

一、进程

  1.进程就是出于执行期的程序

  2.执行线程,简称线程,是在进程中活动的对象

  3.进程提供两种虚拟机制:虚拟处理器和虚拟内存

二、进程描述符和任务结构

  1.内核把进程的列表存放在焦作任务队列的双向循环链表中,其中每一项都是类型为task_struct、称为进程描述符的结构

  2.进程描述符的分配和存放:

    (1)目的:Linux通过slab分配task_struct结构,以达到对象复用以及和缓存着色的目的(避免资源动态分配和释放带来的资源消耗)

    (2)分配:每个任务的堆栈尾端(比如,对于向上增长的堆栈来说,就是在堆栈的栈顶)有结构体thread_info,它指向了task_struct结构体

    (3)查找:

      1)内核中的大部分处理处理进程的代码都是通过task_struct进行的;因此,需要通过current宏查找到当前正在运行进程的进程描述符    

      2)X86系统中,current把栈指针的后13个有效位屏蔽掉,用来计算出thread_info的偏移(通过current_thread_info函数)

  3.进程状态

    进程在任何时刻,都必定处于五种状态中的一种

    (1)TASK_RUNNING

    (2)TASK_INTERRUPT

    (3)TASK_UNINTERRUPT

    (4)TASK_TRACED

    (5)TASK_STOPPED

  4.设置进程当前状态

    (1)调用set_task_state(task,state)函数将进程设置为指定状态

  5.进程上下文

    (1)可执行代码从一个可执行文件载入到进程的地址空间执行。当一个程序执行了系统调用,内核就会“代表进程执行”并处于进程上下文中

    (2)对比:在中断上下文中,系统不代表进程执行——不会有进程去干扰这些中断处理程序

三、进程创建

  1.Unix系统的进程创建方式

    (1)fork()通过拷贝当前进程创建一个子进程

    (2)exec()负责读取可执行文件并将其载入地址空间开始运行

    (3)写时拷贝

      1)Linux的fork()使用写时拷贝推迟甚至免除拷贝。内核在创建新进程的时候并不复制整个地址空间,而是让父进程和子进程共享同一个拷贝;直到子进程/父进程需要写入的时候才进行拷贝

      2)因而,fork的实际开销只是复制父进程的页表以及给子进程创建唯一的进程描述符

  2.fork函数

    (1)Linux通过clone系统调用实现fork

    (2)由clone去调用do_fork()

    (3)定义在<kernel/fork.c>中的do_fork()完成创建中的大部分工作,它调用copy_process函数,然后让进程开始运行

四、内核线程

  1.内核线程:独立运行在内核空间的标准进程。

  2.内核线程没有独立的地址空间只在内核空间运行,从来不切换到用户空间,可以被调度被抢占

  3.内核线程只能由其他内核线程创建

五、进程终结

  1.进程终结时,内核必须释放它所占有的资源并告知父进程。

  2.进程终结的原因:一般是来自自身,发生在调用exit()系统调用时。

  3.删除进程描述符

    通过release_task()实现进程描述符的删除,至此,所有资源都被释放了

  4.解决孤儿进程

    (1)父进程在进程之前退出,就会遗留下子进程,也就是孤儿进程

    (2)解决方法:在当前的线程组内给孤儿进程寻找新的父进程;

            直接以init作为其父进程

以上是关于「图文结合」Linux 进程、线程、文件描述符的底层原理的主要内容,如果未能解决你的问题,请参考以下文章

图文生动形象解释 Linux 进程与线程

图文结合:通俗易懂的Android多进程间通信--binder机制

监控文件描述符的六种方式(进程监控selectpoll非阻塞轮询I/O异步I/O线程监控)

Linux中进程间传递文件描述符的方法

(2.5)文件和目录操作——Linux文件共享

进程和计划任务