说到进程,恐怕⾯试中最常⻅的问题就是线程和进程的关系了,那么先说⼀ 下答案:在Linux系统中,进程和线程⼏乎没有区别。
Linux中的进程就是⼀个数据结构,看明⽩就可以理解⽂件描述符、重定向、管道命令的底层⼯作原理,最后我们从操作系统的⾓度看看为什么说线程和进程基本没有区别。
进程是什么
⾸先,抽象地来说,我们的计算机就是这个东⻄:
这个⼤的矩形表⽰计算机的内存空间,其中的⼩矩形代表进程,左下⾓的圆 形表⽰磁盘,右下⾓的图形表⽰⼀些输⼊输出设备,⽐如⿏标键盘显⽰器等等。另外,注意到内存空间被划分为了两块,上半部分表⽰⽤户空间,下半部分表⽰内核空间。
⽤户空间装着⽤户进程需要使⽤的资源,⽐如你在程序代码⾥开⼀个数组,这个数组肯定存在⽤户空间;内核空间存放内核进程需要加载的系统资源,这些资源⼀般是不允许⽤户访问的。但是注意有的⽤户进程会共享⼀些内核空间的资源,⽐如⼀些动态链接库等等。
我们⽤C语⾔写⼀个Hello World程序,编译后得到⼀个可执⾏⽂件,在命令⾏运⾏就可以打印出⼀句hello world,然后程序退出。在操作系统层⾯,就是新建了⼀个进程,这个进程将我们编译出来的可执⾏⽂件读⼊内存空间,然后执⾏,最后退出。 你编译好的那个可执⾏程序只是⼀个⽂件,不是进程,可执⾏⽂件必须要载入内存,包装成⼀个进程才能真正跑起来。进程是要依靠操作系统创建的,每个进程都有它的固有属性,⽐如进程号(PID)、进程状态、打开的⽂件等等,进程创建好之后,读⼊你的程序,你的程序才被系统执行。
那么,操作系统是如何创建进程的呢?对于操作系统,进程就是⼀个数据结构,我们直接来看Linux的源码:
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指针。mm指向的是进程的虚拟内存,也就是载⼊资源和可执⾏⽂件的地⽅; files指针指向⼀个数组,这个数组⾥装着所有该进程打开的⽂件的指针。
什么是文件描述符
先说files,它是⼀个⽂件指针数组。⼀般来说,⼀个进程会从files[0]读取输⼊,将输出写⼊files[1],将错误信息写⼊files[2]。
举个例⼦,以我们的⾓度C语⾔的printf函数是向命令⾏打印字符,但是从进程的⾓度来看,就是向files[1]写⼊数据;同理,scanf函数就是进程试图从files[0]这个⽂件中读取数据。 每个进程被创建时,files的前三位被填⼊默认值,分别指向标准输⼊流、标准输出流、标准错误流。
我们常说的「⽂件描述符」就是指这个⽂件指针数组的索引,所以程序的⽂件描述符默认情况下0是输⼊,1是输出,2是错误。我们可以重新画⼀幅图。
对于⼀般的计算机,输⼊流是键盘,输出流是显⽰器,错误流也是显⽰器, 所以现在这个进程和内核连了三根线。因为硬件都是由内核管理的,我们的进程需要通过「系统调⽤」让内核进程访问硬件资源。
PS:不要忘了,Linux 中⼀切都被抽象成⽂件,设备也是⽂件,可以进⾏读和写。
如果我们写的程序需要其他资源,⽐如打开⼀个⽂件进⾏读写,这也很简单,进⾏系统调⽤,让内核把⽂件打开,这个⽂件就会被放到files的第4个位置:
明⽩了这个原理,输⼊重定向就很好理解了,程序想读取数据的时候就会去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中新建进程和新建线程都是很迅速的。
声明:以上全文摘抄自《labuladong的算法小抄》