进程,线程彻底剖析

与进程,线程间通信问题梳理

Posted by Haiming on April 11, 2020

如何理解”进程是分配资源的最小单位,线程是操作系统调度的最小单位?“

都说”进程很重“,那么到底重在哪?

进程间的通信到底是什么情况?

请听小周为你一一道来。

参考:https://blog.csdn.net/pange1991/article/details/84770181

1. 什么是计算机资源?

我们正在使用的冯诺依曼体系的计算机,将资源抽象成了三种:

计算资源+存储资源+IO资源。

  1. 计算资源:CPU?CPU只是提供了计算能力,只对于输入的数据做运算,但是不管数据和代码的组织。实际上,计算资源是由操作系统来调配的,OS来决定什么时候什么程序获得计算能力,比如分时间片的做法。
  2. 存储资源:实际上我们现在所使用的多级存储,对于操作系统而言统称为”存储资源“。可以说的是“进程是面向磁盘的”。因为进程表示一个运行的程序,而程序的代码段,数据段等等都是放在磁盘之中的。不管是内存也好,CPU的一二三级cache也好,实际上都是缓存,是为了提高访问的效率,而不是和磁盘有本质的不同。所以虚拟内存面向的是磁盘,虚拟页是对磁盘文件的分配,然后被缓存到物理内存的屋里也之中。
  3. 所以存储资源是由OS直接管理和分配的。进程是操作系统分配存储资源的最小单元。

线程?理论上Linux是没有这个概念的,只有Kernel Scheduling Entry,KSE这个概念。Linux的线程,本质上是一种轻量级的进程,是通过clone系统调用来创建的。进程是一种KSE,线程也是一种KSE,所以说线程是OS调度的最小单元这个话没问题。

2. 什么是进程

进程就是对计算机计算过程的一种抽象。

  1. 进程标识一个逻辑控制流,那么对于这个计算过程,其就造成一种假象,好像这个进程一直在独占CPU资源。
  2. 进程拥有一个独立的虚拟内存空间,造成一个假象,好像其一直在独占存储器资源。

img

看个图。

不需要将每一块的意义都讲出来,我们今天在这里简述。这个图是进程的虚拟空间的分配模型图,可以看到其分为用户空间和内核空间,用户空间从低位到高位发展,存放的是这个进程的代码段和数据段,以及运行时候的堆和用户栈。内核空间从高位到低位,存放着内核的代码和数据,以及内核为这个进程创建的相关数据结构,比如页表等。

除了内存之外,还有文件操作符的保存。Linux之中“一切皆文件”。使用open系统调用,可以返回一个整数作为文件描述符file descriptor,那么进程就可以使用file descriptor来作为参数在任何系统调用之中标识那个打开的文件,内核为进程维护了一个文件描述符表来保持进程所有获得的file descriptor。

img

每调用一次open系统调用内核会创建一个打开文件open file的数据结构来表示这个打开的文件,记录了该文件目前读取的位置等信息。打开文件又唯一了一个指针指向文件系统中该文件的inode结构。inode记录了该文件的文件名,路径,访问权限等元数据。

操作操作系统用了3个数据结构来为每个进程管理它打开的文件资源。

2.1 fork 系统调用

操作系统会使用fork系统调用来创建一个子进程,fork所创建的子进程会复制父进程的虚拟地址空间。一开始是真的copy一份过来,后面使用了copy on write 方式来节省内存,原理是fork刚创建的子进程采用了共享的方式,只是指针指向父进程的物理资源,只有当子进程需要对某些物理资源写操作的时候,才会真正自己独立维护一块物理资源来给子进程使用。这样就极大的优化了fork的性能,并且从逻辑来说子进程的确是拥有了独立的虚拟内存空间

img

fork复制了页表结构,文件描述符表,信号控制表,进程信息,寄存器资源等等,是深入的复制。

从逻辑控制流的角度,fork创建的子进程开始执行的位置,是fork函数返回的位置。这一点和线程不同,Thread之中需要写run方法,线程开始之后会从run开始执行。

那么当进程切换的时候,这些内核为进程维护的资源都得写在内存之中。包括:

  1. 页表——对应虚拟内存资源
  2. 文件描述符表:对应打开的文件资源
  3. 寄存器:对应运行时数据
  4. 信号控制信息/进程运行信息

3. 什么是线程

并发的本质是时间上重叠,那么就需要其对资源进行并发访问。但是由我们之前概括的,进程之间很难共享资源,因为彼此的内存是独立的。

但是线程就没有这个问题。为什么?因为所有的线程是共享一块虚拟内存的,也就是从线程的角度而言,其看到的物理资源都是一样的。那么共享内存就可以直接解决线程通信的问题。而线程也表示一个独立的逻辑流。

3.1 clone 系统调用

clone——轻量fork。其只是表示可以共享父类的某些资源。注意,共享,是没有自己的独立空间的。其只是一个指针而已。

clone可以指定创建的线程开始执行代码的位置,也就是run()

线程的上下文切换只需要保存线程的运行时数据,比如id,寄存器之中的值等等。

进程是父子结构,init进程是最顶端的父进程,其他进程都是其派生出来的,但是线程的话是对等结构,所有线程都属于线程组。

一个应用程序可以有多个进程,执行多个程序代码,多个线程只能执行一个程序代码,共享进程的代码段。

4. 进程之间通信

我们之前提及了,对于进程而言,每个进程有不同的用户地址空间,任何一个进程的全局变量在另一个进程之中都看不到。那么进程之间的通信必须通过内核:先在内核之中开辟一块空间,然后进程1将数据从用户空间拷贝到内核的缓冲区,进程2再从内核缓冲区将数据读走。内核提供的这种机制称为“进程间通信(IPC)”。

image-20200411215437991

4.1 进程之间通信的七种方式

4.1.1 匿名管道(pipe)

其实其就是一个内核缓冲区,进程以FIFO的方式从缓冲区存取数据。当缓冲区写满或者读空的时候,相应的进程会进入等待队列,当空的缓冲区由数据写入,或者满的缓冲区有数据被读出的时候,就控制等待队列之中的相应进程进行继续读写。

匿名管道的局限性:

  1. 只支持单向数据流
  2. 只能用于亲缘关系的进程之间。

做个问题:为什么只能用于具有亲缘关系的进程之间?我的猜想是管道没有名字,那么就没有办法给其一个全局唯一的标识符。但是我们上面讲到了,具有亲缘关系的进程之间的文件描述符是共享的,而我们也提到了,匿名管道是作为文件的格式来被操作的。

本质上,匿名管道是在内核空间申请的一块内存,然后操作系统将其当做一个文件,每次调用的时候创建一个inode 关联read 和 write。那么文件描述符是共享的,自然就能找到对应的入口进行读写,而其他非共享的自然就GG咯。

  1. 管道的缓冲区是有限的:管道只存在于内存之中,当其被创建的时候,为缓冲区分配一个页面大小。
  2. 管道传输的是无格式字节流,那么管道的两端必须自己商议好报文的意义。

4.1.2 有名管道(FIFO)

和匿名管道的不同在于提供了一个路径名与其关联,那么任何两个进程,不需要有亲子关系,也可以试用其进行相互通信。

梳理一下无名管道和有名管道的阻塞问题:

无名管道不需要显式打开,创建的时候直接返回文件描述符。读写的时候需要确定对方的存在,不然直接退出。如果当前进程向无名管道的一端写数据,必须确定另一端有某一进程。

有名管道在打开的时候确实需要对方存在,否则将阻塞。

4.1.3 信号(signal)

通知某个进程其时间已经发生,是进程间通信的唯一的一种异步通信方式。如果某一个信号被进程设置为阻塞,那么其传递将会被延迟,直到阻塞取消才会被传递给进程。、

常用的比如 SIGINT,程序终止信号。按下 Ctrl+C 就会产生该信号。

4.1.4 消息队列(message queue)

是存放在内核之中的消息链表,只有内核重启的时候其才会被清除。允许多个进程写入,允许消息的随机查询,而且解决了信号的承载信息量小,管道只能承载无格式字节流和缓冲区太小等缺点。

4.1.5 共享内存(shared memory)

通俗易懂,直接划出一块内存两个进程共享。

4.1.6 信号量(Semaphore)

信号量之中有PV操作,请求访问资源的时候是P操作,释放资源的时候是V操作。其中P操作的时候会将信号量-1,V操作的时候会将信号量+1,当信号量为0的时候,无法进行P操作,试图做P操作的进程将被放入缓冲队列。

信号量和普通整形变量的区别?

信号量非负,而且只有PV两种原子操作。wait(semap) , signal(semap)

互斥量和信号量的区别?

互斥量只可为0或1,但是信号量可以是非负整数。互斥量的加锁和解锁必须是同一个线程,但是信号量可以由一个线程释放,再由另一个线程拿到

4.1.7 套接字(socket)

用于不在一台机器上面的进程进行通信,很典型的就是tcp/Ip

5. 线程间同步

注意此处,不叫线程间通信,而叫做线程间同步。因为不同线程之间本来就是数据互相透明的,那么就不需要“互相通知”,而是需要在修改某一个资源的时候告知其他的线程“我正在修改,不要和我抢”。线程间的同步分为三种方式:

  1. 互斥量(Mutex):互斥量,只有拥有互斥对象的线程才可以访问,因为互斥对象只有一个,所以可以保证公共资源不被多个线程同时访问。比如Java之中的Synchronized 和各种 Lock 都是这种机制。
  2. 信号量(Semphares):允许多个线程访问同一个资源
  3. 事件(Event):Wait/Notify:通过通知操作的方式来保持多线程的同步,还可以方便的实现多线程优先级的比较操作。

注意:这三种方式都可以用于跨进程的线程同步。