序言
在一开始,我们要明白在Linux中,进程与线程几乎没有区别。
进程
首先,存放在硬盘的可运行的文件本身只是一个文件,不是进程,这点我们在操作系统里属于是刻进脑子里的知识。
在我们电脑里的一个可运行的文件想要变成一个进程,就需要操作系统加载该文件的代码和数据到内存中,并为其创建相应的进程。
操作系统会为新进程分配内存空间,初始化进程控制块(PCB),设置进程的上下文信息,包括程序计数器、堆栈指针等,然后将进程加入到调度队列中等待执行。
操作系统还会负责处理进程的状态转换、调度和资源管理,以确保进程能够正常运行。
当成为进程以后,他便会被操作系统赋予进程号(
PID
)、进程状态、进程组ID(PGID
)、打开的文件等等,进程创建好后,经过操作系统调度,分配到 CPU 上执行时,才会被操作系统运行起来。
Linux很小一部分的内核源码(Linux内核源码是用C语言写的):
struct task_struct {
// 进程状态
long state;
// 虚拟内存结构体
struct mm_struct *mm;
// 进程号
pid_t pid;
// 指向父进程的指针
struct task_struct __rcu *parent;
// 子进程列表
struct list_head children;
// 存放文件系统信息的指针
struct fs_struct *fs;
// 一个数组,包含该进程打开的文件指针
struct files_struct *files;
};
task_struct
就是 Linux 内核对于一个进程的描述,也可以称为「进程描述符」。
根据上面源码可以看到 mm
指向的是进程的虚拟内存,也就是载入资源和可执行文件的地方;files
指针指向一个数组,这个数组里装着所有该进程打开的文件的指针。
这里要重点说明一下 files
,它是一个文件指针数组。一般来说一个进程会从 files[0]
读取输入,将输出写入 files[1]
,将错误信息写入 files[2]
。
这里 labuladong
文章里举了个很好的例子,我就直接记下来了:
举个例子,以我们的角度 C 语言的
printf
函数是向命令行打印字符,但是从进程的角度来看,就是向files[1]
写入数据;同理,scanf
函数就是进程试图从files[0]
这个文件中读取数据。每个进程被创建时,
files
的前三位被填入默认值,分别指向标准输入流、标准输出流、标准错误流。我们常说的「文件描述符」就是指这个文件指针数组的索引,所以程序的文件描述符默认情况下 0 是输入,1 是输出,2 是错误。对于一般的计算机,输入流是键盘,输出流是显示器,错误流也是显示器,所以现在这个进程和内核连了三根线。因为硬件都是由内核管理的,我们的进程需要通过「系统调用」让内核进程访问硬件资源。
系统调用:系统调用(System Call)是操作系统提供给应用程序使用的一种编程接口,用于访问操作系统内核提供的服务和资源。应用程序通过系统调用可以请求操作系统执行特权指令,以便完成诸如文件操作、进程管理、网络通信等底层任务。
通常系统调用的步骤:
- 用户态到内核态切换:当应用程序需要执行系统调用时,它会通过特定的语言特性或库函数(如C库中的
syscall
函数)发起系统调用请求。这将触发用户态到内核态的切换,将控制权从应用程序转交给操作系统内核。- 参数传递:应用程序需要将系统调用所需的参数传递给操作系统内核。通常,这些参数会被放入特定的寄存器或栈中,以便内核能够获取并使用这些参数。
- 执行系统调用:一旦参数传递完毕,操作系统内核会根据系统调用号来确定应该执行哪个具体的系统调用功能。内核会执行相应的特权指令来完成请求的操作,如打开文件、分配内存、创建进程等。
- 处理返回值:执行完系统调用后,内核会将结果返回给应用程序。通常,返回值会被存放在特定的寄存器或内存位置中,应用程序可以通过这些返回值得知系统调用的执行结果。
- 内核态到用户态切换:最后,操作系统内核会将控制权还给应用程序,完成系统调用处理过程,应用程序可以继续执行其他任务。
总的来说,系统调用提供了应用程序与操作系统内核之间的标准接口,使得应用程序可以利用操作系统提供的功能和资源,同时也保证了操作系统对这些资源的合理调度和管理。
上图我们基本上就能很清晰的搞懂 files
的前三位值作为文件描述符默认状况下的作用。
如果我们写的程序需要其他资源,比如打开一个文件进行读写,这也很简单,进行系统调用,让内核把文件打开,这个文件就会被放到 files
数组中第 4 个位置。
明白了这个原理,输入重定向就很好理解了,程序想读取数据的时候就会去 files[0]
读取,所以我们只要把 files[0]
指向一个文件,那么程序就会从这个文件中读取数据,而不是从键盘。
同理,输出重定向就是把 files[1]
指向一个文件,那么程序的输出就不会写入到显示器,而是写入到这个文件中。
错误重定向也是如此,将 files[3]
指向一个文件,程序的错误输出不会写入到显示器,而是写入到这个文件中。
管道符其实也是异曲同工,把一个进程的输出流和另一个进程的输入流接起一条「管道」,数据就在其中传递。(也就是后一个文件的输入数据是前一个文件的输出数据)
从这里就可以看出,Linux中一切都是文件
的设计思路,无论是设备、程序、真正的文件等,全部都可以读写,统一装在 files
数组中,进程可以通过操作系统进行简单的文件描述符访问相应的资源。
线程
首先要明确的是,多进程和多线程都是并发,都可以提高处理器的利用效率,所以现在的关键是,多线程和多进程有啥区别。
在操作系统的角度来说,一般线程(Thread)和进程(Process)是并发执行的基本单位,它们之间有一些重要的区别,主要包括以下几点:
资源分配:
- 进程是程序的执行实例,拥有独立的地址空间、文件描述符、内存等系统资源。每个进程都有自己的代码段、数据段、堆栈段等资源。
- 线程是进程内的一个实体,共享相同的地址空间和系统资源。线程之间可以直接访问同一进程的内存空间和全局变量,因此线程之间的通信更为方便。
调度:
- 进程是系统进行资源分配和调度的基本单位,操作系统会分配进程所需的资源,并负责进程的调度和管理。
- 线程是调度的基本单位,操作系统将线程调度到可执行状态,并在多核处理器上实现真正的并行执行。
创建开销:
- 创建一个新的进程需要分配独立的内存空间、加载程序映像等操作,因此进程的创建开销较大。
- 创建一个新线程的开销相对较小,因为线程共享相同的地址空间和其他资源,只需要分配独立的线程控制块即可。
通信和同步:
- 进程间通信比较复杂,可以使用管道、消息队列、信号量、共享内存等机制来实现进程间通信。
- 线程间通信更为简单直接,可以通过共享内存、互斥锁、条件变量等方式来实现线程间的数据共享和同步。
- 安全性:
- 由于线程共享同一进程的地址空间,线程之间的数据共享可能导致竞态条件等问题,需要额外的同步机制来确保数据的一致性。
- 进程之间拥有独立的地址空间,数据不会相互影响,因此进程间的数据隔离性更好,但进程间通信的开销较大。
总的来说,进程是操作系统资源分配的基本单位,拥有独立的资源和地址空间;而线程是进程内的执行单元,共享同一进程的资源,可以更高效地实现并发执行和数据共享。
通过理解上述操作系统的进程与现成的区别,我们可以更好的理解Linux中进程与线程的区别。
继序言所说的,为什么Linux中进程与线程为什么几乎没有区别?因为从 Linux 内核的角度来看,并没有把线程和进程区别对待。
在Linux中,我们可以去查发现系统调用函数 fork()
可以新建一个子进程,函数 pthread()
可以新建一个线程。但无论线程还是进程,都是用 task_struct
结构表示的,唯一的区别就是共享的数据区域不同。
struct task_struct {
// 进程状态
long state;
// 虚拟内存结构体
struct mm_struct *mm;
// 进程号
pid_t pid;
// 指向父进程的指针
struct task_struct __rcu *parent;
// 子进程列表
struct list_head children;
// 存放文件系统信息的指针
struct fs_struct *fs;
// 一个数组,包含该进程打开的文件指针
struct files_struct *files;
};
换句话说,线程看起来跟进程没有区别,只是线程的某些数据区域和其父进程是共享的,而子进程是拷贝副本,而不是共享。
这里引用 labuladong
所画的图,通过观察上面的两个图我们就可以很直观的看出区别。
线程虽然可以比进程更加简单直接的实现线程之间的数据共享与同步问题,但我们的多线程程序也要多利用锁机制,避免多个线程同时往同一区域写入数据,否则可能造成数据错乱。
但这样就引出来了一个疑问:既然进程和线程差不多,而且多进程数据不共享,即不存在数据错乱的问题,为什么多线程的使用比多进程普遍得多呢?
答:因为实际数据共享的情况更多,比如十个人同时从一个账户取十元,我们希望的是这个共享账户的余额正确减少一百元,而不是希望每人获得一个账户的拷贝,每个拷贝账户减少十元。而且一个个拷贝显然资源消耗更大。
当然,必须要说明的是,只有 Linux 系统将线程看做共享数据的进程,不对其做特殊看待,其他的很多操作系统是对线程和进程区别对待的,线程有其特有的数据结构。
在 Linux 中新建线程和进程的效率都是很高的,对于新建进程时内存区域拷贝的问题,Linux 采用了 copy-on-write
的策略优化,也就是并不真正复制父进程的内存空间(指整个复制下来),而是等到需要写操作时才去复制。
copy-on-write
策略:当一个进程fork出一个子进程时,操作系统并不会立即复制整个父进程的地址空间,而是采用写时复制(copy-on-write)的策略。这意味着父子进程会共享相同的物理内存页,只有在其中一个进程尝试修改共享的内存页时,操作系统才会复制这个内存页,以确保各自的修改不会影响到对方。这样可以节省内存和时间,提高了新建进程的效率。
相对的,在 Linux 系统中,新建线程时并不需要像新建进程那样进行内存空间的拷贝,因为线程共享同一进程的地址空间和资源。因此,新建线程通常比新建进程的开销更小,因为不需要复制地址空间。
所以 Linux 中新建进程和新建线程都是很迅速的。
评论(0)