第一次作业:深入源码分析理解Linux进程模型

Posted ishlx

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了第一次作业:深入源码分析理解Linux进程模型相关的知识,希望对你有一定的参考价值。

1.Linux操作系统的简单介绍

Linux系统一般有4个主要部分:内核、shell、文件系统和应用程序。内核、shell和文件系统一起形成了基本的操作系统结构,它们使得用户可以运行程序、管理文件并使用系统。

1.1 内核

内核是系统的核心,是运行程序和管理诸如磁盘和打印机等硬件设备的核心程序。操作系统是一个用来和硬件打交道并为用户程序提供有限服务集的低级支撑软件。一个计算机系统是一个硬件和软件的共生体,它们相互依赖、不可分割。外围设备、处理器、内存、硬盘和其他的电子塞河北组成了计算机的发动机,但是如果没有软件来操作和控制它,硬件自身是不能工作的。完成这个控制工作的软件就称为操作系统。在Linux的术语中“内核”也称为“核心”。

Linux内核的主要模块分以下几个部分:存储管理、CPU和进程管理、文件系统、设备管理和驱动、网络通信,以及系统的初始化(引导)、系统调用等。内核最重要的部分就是内存管理和进程管理。

1.2 Shell

Shell是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由Shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果。

Shell中命令分为内部命令和外部命令,前者包含在Shell之中,如cd,exit等,查看内部命令可用help命令.后者存于文件系统某个目录下的具体可操作程序,如cp等。查看外部命令的路径可用which

1.3 文件系统

Linux文件系统是文件存放在磁盘等存储设备上的组织方法。Linux能支持多种目前流行的文件系统。如EXT2、EXT3、FAT、VFAT、ISO9660、NFS、SMB等。

文件系统是Linux操作系统的重要组成部分,Linux文件具有强大的功能。文件系统中的文件是数据的集合,文件系统不仅包含着重要组成部分,所有Linux用户和程序看到的文件、目录、软连接及文件保护信息等都存储在其中。一个文件系统的好坏主要体现在对文件和目录的组织上。目录提供了管理文件的一个方便而有效的途径。使用Linux,用户可以设置目录和文件的权限,以便允许或拒绝其他人对其进行访问。Linux目录采用多级树形结构,用户可以浏览整个系统,可以进入任何一个已授权进入的目录,访问那里的文件。文件结构的相互关联性使共享数据变得容易,几个用户可以访问同一个文件。Linux是一个多用户系统,操作系统本身的驻留程序存放在从根目录开始的专用目录中,有时被指定为系统目录。

1.4 应用系统

标准的Linux系统都有一整套称为应用程序的程序集,包括文本编辑器、编程语言、X-Window、办公套件、Internet工具、数据库等。

2.Linux操作系统的进程组织

2.1 什么是进程

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体;进程是处于执行期的程序以及它所包含的所有资源的总称,包括虚拟处理器,虚拟空间,寄存器,堆栈,全局数据段等。

在Linux中,每个进程在创建时都会被分配一个数据结构,称为进程控制(Process Control Block,简称PCB)。PCB中包含了很多重要的信息,供系统调度和进程本身执行使用。所有进程的PCB都存放在内核空间中。PCB中最重要的信息就是进程PID,内核通过这个PID来唯一标识一个进程。PID可以循环使用,最大值是32768。init进程的pid为1,其他进程都是init进程的后代。

除了进程控制块(PCB)以外,每个进程都有独立的内核堆栈(8k),一个进程描述符结构,这些数据都作为进程的控制信息储存在内核空间中;而进程的用户空间主要存储代码和数据。

2.2 进程的组织

2.2.1 从task_struct开始学习linux内核

一. 数据结构

进程控制块PCB(Process Control Block)是进程存在和运行的唯一标志,在Linux中用task_struct这个结构体来表示。这个结构体中有很多数据项,查看源代码时没必要理解全部的数据项,只需要在以后使用时再理解。

1 struct task_struct  
2 {  
3       ....  
4 };  

下面重点介绍几个基本的数据项:

1. 进程状态

task_struct中用一个长整形state表示进程的状态。

1 volatile long state;  
2. 进程标识符

linux用一个32位无符号整形pid来简单的标识一个进程,用uid和gid分别来标识进程所属的用户和组。

1 pid_t pid;        
2 uid_t uid;  
3 gid_t gid;

pid的上限是由pid_max决定的。编译内核时会让选择0x1000和0x8000两种数值,即pid_max=4096和pid_max=32768两种。

3. 亲属关系
1 struct list_head children;        //子进程链表  
2 struct list_head sibling;        //兄弟进程链表  
3 struct task_struct *real_parent; //真正创建当前进程的进程   
4 struct task_struct *parent;      //养父进程

二. 进程控制块的存放

1.内核栈

当进程从用户态进入内核态时要使用位于内核数据段上的内核栈。

2.数据结构
1 union thread_union  
2 {  
3          struct thread_info thread_info;  
4          unsigned long stack[THERAD_SIZE/sizeof(long)];//内核栈,一般为8KB  
5 }; 

内核中将task_struct的指针放在thread_info结构体中,而这个结构体又与内核栈一块被放在8KB的内核空间thread_union中。

1 struct thread_info{    
2           struct task_struct *task;    
3           struct exec_domain *exec_domain;    
4           .....    
5 };  

2.2.2 linux的进程组织方式

一. 进程链表

每个task_struct中都有一个tasks的域来连接到进程链表上去。

1 struct task_struct{  
2   ...  
3   struct list_head tasks;  
4   ...  
5   char comm[TASK_COMM_LEN];//可执行程序名  
6   ...  
7 };  

而这个链表的头是init_task.它是0号进程的PCB,0号进程永远不会被撤销,它被静态的分配到内核数据段上。也就是Init_task的PCB是由编译器预先分配的,在程序运行的过程中一直存在,直到程序结束。

1 struct task_struct init_task = INIT_TASK(init_task);  

可以编写以下内核模块,得到所有进程的pid和进程名,并统计出进程总数。

 1 #include<linux/kernel.h>  
 2 #include<linux/init.h>  
 3 #include<linux/module.h>  
 4 #include<linux/sched.h>  
 5   
 6 MODULE_LICENSE("GPL");  
 7   
 8 static int __init print_pid_init(void)  
 9 {  
10     struct task_struct *task,*p;  
11     struct list_head *pos;  
12     int count = 0;  
13     printk("Begin to print process :\\n");  
14     task = &init_task;  
15     list_for_each(pos,&task->tasks)  
16     {  
17         p = list_entry(pos,struct task_struct,tasks);  
18                 count++;  
19         printk("%d ======> %s\\n",p->pid,p->comm);  
20     }  
21     printk("the number of process is %d\\n",count);  
22     return 0;  
23 }  
24 static void __exit print_pid_exit(void)  
25 {  
26     printk("End to print process.\\n");  
27 }  
28 module_init(print_pid_init);  
29 module_exit(print_pid_exit);  

二. 哈希表

 由于进程链表是将所有的进程连接到一个链表上去,所以查找一个进程的时间复杂度是O(N),是相当的低效。为此,使用哈希表来提高查找的效率。

1. 哈希表的定义
1 static struct hlist_head *pid_hash;  
2.哈希函数

学过数据结构的应该知道,哈希函数对整个查找是至关重要的,它决定了发生冲突的概率。好的哈希函数能够得到减少冲突。

1 #define pid_hashfn(nr, ns)      \\  
2         hash_long((unsigned long)nr + (unsigned long)ns, pidhash_shift)  
1 #define hash_long(val, bits) hash_32(val, bits)  
1 static inline u32 hash_32(u32 val, unsigned int bits)  
2 {  
3         /* On some cpus multiply is faster, on others gcc will do shifts */  
4         u32 hash = val * GOLDEN_RATIO_PRIME_32;  
5   
6         /* High bits are more random, so use them. */  
7         return hash >> (32 - bits);  
8 } 
1 /* 2^31 + 2^29 - 2^25 + 2^22 - 2^19 - 2^16 + 1 */  
2 #define GOLDEN_RATIO_PRIME_32 0x9e370001UL 
3. 通过pid查找task_struct

内核并不能直接通过pid找到对应的task_struct,而是先通过pid找到对应的struct pid,在通过struct pid 找到对应的task_struct。

下面是详细介绍这两个的链接:

i.pid到struct pid内核函数详解

ii.struct pid 到task_struct的内核函数详解

三. 就绪队列

与进程链表类似,task_struct也定义了一个连接到就绪队列的域run_list。

 1 struct sched_rt_entity {  
 2         struct list_head run_list;  
 3         ....  
 4 };  
 5 struct task_struct  
 6 {   
 7         ....  
 8         struct sched_rt_entity rt;  
 9         ......  
10 };  

就绪队列头:

同样,内核中有一个就绪队列头runqueue_head。

四. 等待队列

1. 等待队列的数据结构
1 typedef struct __wait_queue wait_queue_t;  
2 struct __wait_queue {  
3         unsigned int flags;  
4 #define WQ_FLAG_EXCLUSIVE       0x01  
5         void *private;  
6         wait_queue_func_t func;  
7         struct list_head task_list;  
8 };  

task_list链接到等待队列上去。

func是一个函数指针,指向唤醒等待队列中进程的函数。prviate是传递给func的参数,用于指定所要唤醒的进程。

1 typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);

flags标志进程是否互斥:flags为WQ_FLAG_EXCLUSIVE  时互斥,否则,非互斥。

2. 等待队列头
1 struct __wait_queue_head {  
2         spinlock_t lock;  
3         struct list_head task_list;  
4 };  
5 typedef struct __wait_queue_head wait_queue_head_t;  

因为等待队列是由中断处理程序和主要的内核函数修改的,因此要避免被同时访问。lock自旋锁对其进行同步,避免了双向链表被同时访问。task_list是双向链表的节点。

3. 等待队列的操作

i. 初始化

首先,使用了下面的宏声明并初始化了一个等待队列头。

1 #define __WAIT_QUEUE_HEAD_INITIALIZER(name) {                           \\  
2         .lock           = __SPIN_LOCK_UNLOCKED(name.lock),              \\  
3         .task_list      = { &(name).task_list, &(name).task_list } }  
4   
5 #define DECLARE_WAIT_QUEUE_HEAD(name) \\  
6         wait_queue_head_t name = __WAIT_QUEUE_HEAD_INITIALIZER(name) 

如果要对列中一个元素初始化,要使用这个函数:

1 static inline void init_waitqueue_entry(wait_queue_t *q, struct task_struct *p)  
2 {  
3         q->flags = 0;  
4         q->private = p;  
5         q->func = default_wake_function;//default_wake_function能够唤醒睡眠的进程p,并将其从等待队列中删除。  
6 }

ii.插入/删除

add_wait_queue()将一个非互斥进程插入到等待队列的第一个位置。

1 void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)  
2 {  
3         unsigned long flags;  
4   
5         wait->flags &= ~WQ_FLAG_EXCLUSIVE;  
6         spin_lock_irqsave(&q->lock, flags);  
7         __add_wait_queue(q, wait);  
8         spin_unlock_irqrestore(&q->lock, flags);  
9 } 

add_wait_queue_exclusive()将一个互斥进程插入到等待队列的最后一个位置 。

1 void add_wait_queue_exclusive(wait_queue_head_t *q, wait_queue_t *wait)  
2 {  
3         unsigned long flags;  
4   
5         wait->flags |= WQ_FLAG_EXCLUSIVE;  
6         spin_lock_irqsave(&q->lock, flags);  
7         __add_wait_queue_tail(q, wait);  
8         spin_unlock_irqrestore(&q->lock, flags);  
9 } 

之所以将非互斥进程放在队首,而将互斥进程放在队尾,大概是因为非互斥进程不需要临界资源,将它唤醒不会影响其它进程的执行,而互斥进程得到临界资源会导致别的进程无法执行。

 remove_wait_queue()将一个进程从等待队列中删除。

1 void remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)  
2 {  
3         unsigned long flags;  
4   
5         spin_lock_irqsave(&q->lock, flags);  
6         __remove_wait_queue(q, wait);  
7         spin_unlock_irqrestore(&q->lock, flags);  
8 } 

iii.检查是否为空队列,直接调用了list.h中的list_empty()

1 static inline int waitqueue_active(wait_queue_head_t *q)  
2 {                 
3         return !list_empty(&q->task_list);  
4 } 

iv.睡眠

 1 sleep_on_common(wait_queue_head_t *q, int state, long timeout)  
 2 {  
 3         unsigned long flags;  
 4         wait_queue_t wait;   
 5   
 6         init_waitqueue_entry(&wait, current);  
 7   
 8         __set_current_state(state);  
 9   
10         spin_lock_irqsave(&q->lock, flags);  
11         __add_wait_queue(q, &wait);  
12         spin_unlock(&q->lock);  
13         timeout = schedule_timeout(timeout);  
14         spin_lock_irq(&q->lock);  
15         __remove_wait_queue(q, &wait);  
16         spin_unlock_irqrestore(&q->lock, flags);  
17   
18         return timeout;  
19 }

v.唤醒

 1 static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,  
 2                         int nr_exclusive, int wake_flags, void *key)  
 3 {  
 4         wait_queue_t *curr, *next;  
 5   
 6         list_for_each_entry_safe(curr, next, &q->task_list, task_list) {  
 7                 unsigned flags = curr->flags;  
 8   
 9                 if (curr->func(curr, mode, wake_flags, key) &&  
10                                 (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)  
11                         break;  
12         }  
13 } 

3.Linux操作系统的进程控制

3.1进程的各个状态

为了更好地理解进程控制,我们需要知道进程状态这个概念。和其他普通事物一样,进程始终处于一系列的状态中,比如我们至少可以想象出“运行”,“休眠”之类的。

 

TASK_RUNNING:

可执行状态。这是 “进程正在被CPU执行” 和 “进程正在可执行队列中等待被CPU执行” 统称。也可以将它们拆开成“RUNNING”和“READY”两种状态。

 

TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE:

可中断的睡眠状态 和 不可中断的睡眠状态。处于睡眠状态的进程不会被调度到CPU进行执行,而是否可中断的意思是指进程是否会响应异步信号,如果是可中断的,当进程收到某个信号时其会重新回到TASK_RUNNING状态。值得注意的是,如果处于不可中断的睡眠状态时,进程将不响应异步信号,比如你无法“kill -9”。

 

TASK_STOPPED:

暂停状态。这里的STOPPED是指停止运行(暂停),而不是进程终止。向进程发送SIGSTOP信号可以让进程暂停下来,相反,发送SIGCONT可以将其从TASK_STOPPED状态唤醒而重新进入TASK_RUNNING状态。

 

TASK_TRACED:

被跟踪状态。一个进程被另一个进程“TRACE(跟踪)"最经典的例子是DEBUG,比如使用gdb或任何一款ide的debug功能。TASK_TRACED和TASK_STOPPED非常相近,都是让进程暂停下来,区别是不能通过向TASK_TRACED的进程发送SIGCONT信号让其恢复,只能由跟踪该进程的那个进程发送PTRACE_CONT,PTRACE_DETACH等,也就是说得让跟踪进程来决定是否挂起或继续被跟踪进程,当然,跟踪进程如果退出的话,被跟踪进程也会重新回到TASK_RUNNING状态。

 

TASK_DEAD:

僵尸状态。很搞笑的名字,之所以是“僵尸”而不是“死亡”是因为进程已不响应任何信号以及大部分相关数据已被清除,但其TASK_STRUCT结构仍存在,这个结构相当于进程的“躯壳”,还保留着一些信息,父进程可以利用这些信息得到进程终止前的一些状态。如果你看到某些文档上描写的ZOMBIE也是指的这个状态。

 

下图描述了进程各个状态之间的相互转化:

3.2 退出/终止进程

 void _exit(int status) 与 void exit(int status)

这两个函数都是让进程退出, 参数status表示进程将以何种状态退出,在<stdlib.h>中预定义了一些状态,比如EXIT_SUCCESS(值为0)表示以成功状态退出,EXIT_FAILURE(值为1)表示以失败状态退出。

调用_exit函数时,其会关闭进程所有的文件描述符,清理内存以及其他一些内核清理函数,但不会刷新流(stdin, stdout, stderr  ...).   exit函数时在_exit函数之上的一个封装,其会调用_exit,并在调用之前先刷新流。

参考下面这段代码:

 1 #include <stdio.h>    //for printf(const char *)
 2 #include <unistd.h>   //for fork()
 3 #include <sys/wait.h> //for wait(int *)
 4 #include <stdlib.h>   //for EXIT_SUCCESS
 5 
 6 
 7 int main ()
 8 {
 9     printf("app start...\\n");
10     
11     if(fork() == 0)
12     {
13         printf("do something in child process ...\\n");
14         
15         exit(EXIT_SUCCESS); 
16         
17         printf("this will not been executed\\n");
18     }
19     
20     int status;
21     wait(&status);
22         
23     printf("app end\\n");
24         
25     return 0;
26 }

上面的代码无论时用exit还是_exit输出结果都如下:

1 app start...
2 do something in child process ...
3 app end

这是因为stdout缓冲区是按行缓冲的,当遇到换行符时会刷新当前缓冲区,所以当进程退出前即便_exit不刷新,"do somethign in child process "这句话仍然被输出到了屏幕上。

现在我们将使用不带换行符的printf, 并且也不调用fflush之类的函数,在使用_exit试试:

 1 #include <stdio.h>    //for printf(const char *)
 2 #include <unistd.h>   //for fork()
 3 #include <sys/wait.h> //for wait(int *)
 4 #include <stdlib.h>   //for EXIT_SUCCESS
 5 
 6 
 7 int main ()
 8 {
 9     printf("app start...\\n");
10     
11     if(fork() == 0)
12     {
13         printf("do something in child process ...");
14         
15         _exit(EXIT_SUCCESS); 
16         
17         printf("this will not been executed\\n");
18     }
19     
20     int status;
21     wait(&status);
22         
23     printf("app end\\n");
24         
25     return 0;
26 }

输出结果为:

1 app start...
2 app end

如果换成exit则输出结果为:

1 app start...
2 do something in child process ...app end

void abort ()

非正常地退出进程。其会产生一个SIGABORT信号(关于信号,会在下一篇“进程间通信”介绍),然后使进程戛然而止,也就意外着其不会进行清理工作, 但它会刷新缓冲区。

 1 #include <stdio.h>    //for printf()
 2 #include <unistd.h>   //for fork()
 3 #include <sys/wait.h> //for wait()
 4 #include <stdlib.h>   //for EXIT_SUCCESS
 5 
 6 int main ()
 7 {
 8     printf("app start...\\n");
 9     
10     if(fork() == 0)
11     {
12         printf("do something in child process ...");
13         
14         abort();
15         
16         printf("this will not been executed\\n");
17     }
18     
19     int status;
20     wait(&status);
21         
22     printf("app end\\n");
23         
24     return 0;
25 }

输出为:

1 app start...
2 do something in child process ...app end

void atexit( void (*f) () )

如果想在进程正常结束之前干一点自定义的事情,就可以调用这个函数.  其简单地利用你传入的函数指针执行一个函数回调。

值得注意的是:其仅仅在调用exit函数结束进程或进程执行完所有代码后自然结束这两种状态下,回调函数才会被执行,也就是说如果进程是被_exit或abort结束的,则atexit函数无效。

 1 #include <stdio.h>    //for printf()
 2 #include <unistd.h>   //for fork()
 3 #include <sys/wait.h> //for wait()
 4 #include <stdlib.h>   //for EXIT_SUCCESS
 5 
 6 void before_exit()
 7 {
 8     printf("1,2,3 exit!\\n");
 9 }
10 
11 int main ()
12 {
13     printf("app start...\\n");
14     
15     if(fork() == 0)
16     {
17         printf("do something in child process ...\\n");
18         
19         void (*f)() = before_exit;
20         atexit(f);
21         
22         exit(EXIT_SUCCESS);
23         
24         printf("this will not been executed\\n");
25     }
26     
27     int status;
28     wait(&status);
29         
30     printf("app end\\n");
31         
32     return 0;
33 }

输出为:

1 app start...
2 do something in child process ...
3 1,2,3 exit!
4 app end

3.3暂停进程

int pause()

暂停进程,可以使用pause函数,其会挂起当前进程直到有信号来唤醒或者进程被结束。

随便提一下,如果你仅仅需要简单地暂停一下(press any key  to continue...), 可以使用 system("pause")这个系统调用,甚至是getch()之类的。 

关于pause这个函数的Demo和更详细的理解,由于其会涉及到比较多与“信号”相关的知识,所以我打算放到下一篇“进程间通信”来讲

 

unsigned sleep(unsigned seconds  

int usleep(useconds_t useconds)

int nanosleep(const struct timespec *rqtp, struct timespec *rmtp)

sleep系列函数都是让进程挂起一段时间,sleep只能精确到秒,usleep能精确到微妙,而nanosleep传说精度更高。 

3.4 进程跟踪 

long ptrace(/*some args*/)

要像debug程序一样去跟踪进程,是一个比较复杂的问题。

3.5 waitpid 与 wait(等待子进程结束)

大家经常看到的关于waitpid的经典例子是:你下载了某个软件的安装程序A,其在安装即将结束时启动了另外一个流氓软件的安装程序B,当B也安装结束后,其告诉你所有安装成功了。A和B分别在不同的进程中,A如何启动B并知道B安装完成了呢?可以很简单地在A中用fork启动B,然后用waitpid(或wait)来等待B的结束。

pid_t waitpid(pid_t pid, int *stat_loc, int options);

参数pid:
如果大于0,表示父进程所需要等待的子进程的进程号

  如果等于0,则表示任意任意group id和父进程相同的子进程

  如果等于-1, 则表示等待任意子进程(有多个子进程时,任意进程结束,函数都会返回),此时waitpid和wait相同。

  如果小于-1,则取其绝对值作为需要等待的子进程的进程号

参数stat_loc:
表示进程退出时进程状态的存储位置,有一些专门的宏类根据该位置计算状态值,可以参考这里

参数options:

  这个参数控制函数是否立即返回,它有三个值:0,WNOHANG(值为1),WUNTRACED(值为2),这三个值多少让有有些迷惑,有个帖子中是如此说的:options的各个常量不是互斥关系,而是通过按位或运算组合起来的关系。进程的状态数是有限的,所有的进程状态改变可能性,是一个元素个数有限的集合,waitpid中指定的子进程的状态改变,必然是这个集合的子集,记为A。options决定如何取A中的元素,默认时(0),只有A不是空集的时候,才会返回,否则阻塞。WNOHANG 告诉waitpid,即使A是空集,也不会挂起,而是立即返回。WUNTRACED 告诉waitpid,如果A中含有进程STOPED状态,也立即返回。如果是被trace的子进程,那么即使不提供WUNTRACED参数,也会理解返回。 

另外,关于waitpid和wait的关系: wait(&status) 等于 waitpid(-1, &status, 0)

 

 1 #include <stdio.h>    //for printf()
 2 #include <unistd.h>   //for fork()
 3 #include <sys/wait.h> //for wait()
 4 #include <stdlib.h>   //for EXIT_SUCCESS
 5 
 6 
 7 int main ()
 8 {
 9     printf("app start...\\n");
10     
11     printf("do something in main process\\n");
12     
13     sleep(5);
14     
15     if(fork() == 0)
16     {
17         printf("do something in child process ...\\n");
18         
19         sleep(5);
20         
21         exit(EXIT_SUCCESS);
22         
以上是关于第一次作业:深入源码分析理解Linux进程模型的主要内容,如果未能解决你的问题,请参考以下文章

第一次作业:深入源码分析理解Linux进程模型

第一次作业:基于Linux系统深入源码分析进程模型

第一次作业:基于Linux0.01深入源码分析进程模型

第一次作业:深入源码分析进程模型

第一次作业:深入源码分析进程模型

第一次作业:深入源码分析进程模型