什么是进程
想要了解什么是进程,或者说,为什么会有进程这个概念,我们就需要去了解现代计算机的设计框架(冯·诺依曼体系):
计算机从设计之初就以执行程序为核心任务,也就是运算器从内存中读取,也只从内存中读取,输入设备也只能输入到内存中。在早期计算机系统中,计算机一次只能运行一个程序,所有资源都被这个程序独占。随着需求的增加,计算机需要能够同时运行多个任务,比如一个任务处理文件读写,另一个任务计算数据。这时,系统需要一种机制来管理多个程序的执行,确保它们能够公平、有效地利用计算机的资源,这就是进程概念产生的原因。
进程的定义
进程是操作系统中执行程序的一个实例,它包含了程序运行所需的所有信息和资源。简单来说,进程就是操作系统为运行中的程序提供的一个执行环境,包括程序代码、数据、打开的文件、内存空间和处理器时间等。一个程序在操作系统中可能运行多个实例,每个实例就是一个独立的进程。
每个进程通常包含以下几个部分:
- 可执行程序代码:程序的二进制指令。
- 进程的内存空间:包括堆、栈、数据段等。
- 进程状态信息:CPU寄存器、程序计数器等,记录当前进程执行的具体状态。
- 调度信息:操作系统需要的信息,用来决定何时执行该进程。
这些也就是计算机或者说是操作系统用来描述每一个需要执行的程序的方法,在linux里,描述程序的结构体叫做 task_struct 也叫做PCB (Process Control Block,进程控制块)PCB是一个概念,task_struct则是在linux里PCB的具体表现。
struct task_struct {
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
struct thread_info *thread_info;
atomic_t usage;
unsigned long flags; /* per process flags, defined below */
unsigned long ptrace;
int lock_depth; /* Lock depth */
int prio, static_prio;
struct list_head run_list;
prio_array_t *array;
}
这是在 linux-2.6.11.1 截取的task_struct 片段,通过这个片段,我们可以看到操作系统用来描述进程信息的部分属性。
state
表示进程的当前状态(可运行、不可运行等)。usage
是引用计数,记录进程被使用的次数。prio
和static_prio
是进程的优先级,用于调度。run_list
用于将进程挂入运行队列,array
管理进程的优先级队列。
struct list_head {
struct list_head *next, *prev;
};
而这里链接进程进入运行队列的结构就是这个结构体,
通过上篇博客(不同结构体之间的链接-CSDN博客)的字节运算从而获得每一个结构体的成员。
为什么需要进程?
引入进程的核心原因在于多任务处理和资源管理。现代计算机需要同时运行多个任务,而进程是管理这些任务的一种机制。进程可以:
- 隔离任务:每个进程运行在自己的内存空间中,避免相互影响。这种隔离性提高了系统的安全性和稳定性。
- 资源管理:操作系统通过进程来分配 CPU、内存和I/O设备等资源,确保每个进程都能公平地使用系统资源。
- 并发执行:通过多任务调度,操作系统可以在一个 CPU 上快速切换进程,使得多个进程看似同时执行,提升系统的利用效率。
进程的出现,使得操作系统能够高效管理多个任务,并为每个任务提供独立、受保护的执行环境。
查看进程
在window操作系统中,由于图形化的原因,我们可以通过任务管理器更加方便直接的看到当前计算机正在运行的进程,同时我们还可以看到他的基本信息,那么在Linux系统中,我们该如何查看进程呢 ?
我们可以直接在 /proc/ 文件夹里查看进程
这些蓝色的数字就是进程的PID (唯一标识符) ,如果我们想要查进程信息,同样可以进入文件夹内查看
大多数进程信息同样可以使用top和ps这些用户级工具来获取
我们可以写一个一直循环的进程用来使用ps查询
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1)
{
sleep(5); // 暂停 5 秒
printf("mypid: %d\n");
}
return 0;
}
为什么有两个呢,因为作为查询任务的 grep 也是一个进程 ,同样,我们也可以使用系统接口,在程序内获取进程PID 和父进程的PID (PPID)
每一次我们执行程序,进程PID都是改变的
top命令大多数情况下都是用在进程优先级上,我们在优先级那里在回顾top的使用
创建进程
在系统给出的系统调用接口中,也有创建子进程的相关函数,那就是 fork() 。
fork()
函数是 Unix 和 Linux 系统中用于创建新进程的系统调用。它是进程控制的一部分,用于实现多任务处理。fork()
在调用时会创建一个新的进程,这个新进程是调用进程的一个副本,称为子进程
- 创建新进程:
fork()
会复制调用进程的所有属性,创建一个新的进程。 - 返回值:
- 在父进程中,
fork()
返回新创建的子进程的 PID。 - 在子进程中,
fork()
返回0
。 - 如果
fork()
失败,它会返回-1
,并设置errno
以指示错误原因。
- 在父进程中,
我们通常使用 if 来分流父进程与子进程
#include<stdio.h>
#include<unistd.h>
int main()
{
pid_t id = fork();
if(id==0) //子进程
{
int i = 5;
while(i--)
{
printf("我是子进程 pid : %d, ppid : %d\n",getpid(),getppid());
sleep(1);
}
}
else if(id>0)
{
int i=7;
while(i--)
{
printf("我是父进程 pid: %d,ppid: %d\n",getpid(),getpid());
sleep(1);
}
}
else
{
perror("进程创建失败\n");
}
return 0;
}
fork()函数
上述代码,我们可以看出来,我们创建了一个子进程,但是,在以往我们学习C语言的经验里,一个程序里,一个值怎么进入两个完全不同的if判断呢,那我们最直观的想法就是,父进程跟子进程使用的数据并不是用一个甚至并没有在一起。
在fork()官访问当中,我们可以看到
内存空间的独立性
-
进程复制:
- 当调用
fork()
时,操作系统会创建一个新的进程(子进程),这个子进程是父进程的一个副本。在内存中,这意味着子进程会有一份与父进程相同的内存内容,包括代码、数据、堆栈等。
- 当调用
-
独立的内存空间:
- 虽然父进程和子进程在
fork()
时内存内容是相同的,但它们的内存空间是独立的。每个进程在其自己的地址空间中操作,因此一个进程的内存更改不会直接影响到另一个进程的内存。
- 虽然父进程和子进程在
-
写时复制(Copy-on-Write):
- 在现代操作系统中,
fork()
采用了一种优化机制叫做写时复制(Copy-on-Write)。最初,父进程和子进程共享相同的物理内存页,只有当其中一个进程试图修改这些内存页时,操作系统才会复制这些页,确保每个进程拥有自己的副本。这种机制减少了fork()
操作的开销。
- 在现代操作系统中,
子进程与父进程的分流
当我们了解 fork()函数后,了解了fork()函数之后的代码是子进程与父进程共有的,当子进程要修改某一个变量的值时,系统才会给子进程拷贝一份这个值,并开辟空间给其进行修改,那我们是不是可以通过取地址 id 的地址来获得子进程存放数据的地址呢?
#include<stdio.h>
#include<unistd.h>
int main()
{
pid_t id = fork();
if(id==0) //子进程
{
printf("%p\n",&id);
}
else if(id>0)
{
printf("%p\n",&id);
}
else
{
perror("进程创建失败\n");
}
return 0;
}
我们惊奇的发现,两个id的地址竟然是一样的,那我们之前的拷贝的id在哪里,同一个地址给出的id值竟然会不一样 ! 其实这是操作系统包含内存的设计,如果让我们的程序简简单单就可以访问到物理地址,那么操作系统很容易就崩溃了。对与每一个进程,或者说是每一个程序,操作系统都平等分配一个虚拟地址空间,通过这个虚拟地址空间跟页表映射到真实的物理地址,那么也就是说,这两个id不同的是物理地址。
当fork函数返回时,对id进行了写时拷贝,子程序的页表重新映射到操作系统存放的新id的物理地址(简图)
当子进程被创建时,PCB等内容被填充,父子进程指向相同的代码,同时,父子进程都有了属于自己的task_struct,可以被操作系统调度并执行了,同时由于,直接拷贝导致消耗过大,也就有了,修改数据时分配空间同时让子进程的页表指向该空间从而获得不同的数据。页表的设计,将虚拟地址跟物理地址连接到一起并且让内存单独维护内存,进程单独维护进程,保护数据的同时,解耦两部分。
ps: Linux 源码下载 Index of /pub/linux/kernel/