创建进程fork与终止

编程入门 行业动态 更新时间:2024-10-12 10:21:46

创建<a href=https://www.elefans.com/category/jswz/34/1771450.html style=进程fork与终止"/>

创建进程fork与终止

在最新的版本的POSIX标准中,定义了进程创建和终止的操作,进程创建包括fork()和execve(),进程终止包括wait(),waitpid(),kill()以及exit()。Linux系统为了提高效率,把POSIX标准的fork()扩展为vfork和clone。

前面一章我们学习了用GCC将一个最简单的程序(如hello world程序)编译成ELF文件,在shell提示符下输入该可执行文件并且按回车后,这个程序就开始执行了。起始这里shell会调用fork()来创建一个新进程,然后调用execve()来执行这个新程序。该函数负责读取可执行文件,将其装入子进程的地址空间并开始执行,这时候父子进程开始分道扬镳。

这一节,我们就来看一看,fork系统调用的实现,创建进程这个动作在内核里都做了什么事情。

1 _do_fork函数分析
在内核中,fork()、vfork()和clone()系统调用通过_do_fork()函数实现,_do_fork()函数实现在kernel/fork.c文件中

long _do_fork(unsigned long clone_flags,unsigned long stack_start,unsigned long stack_size,int __user *parent_tidptr,int __user *child_tidptr,unsigned long tls)

_do_fork函数有6个参数,具体的含义如下:

参数说明
clone_flags创建进程的标志位集合,常见的标志位如下所示
stack_start用户态栈的起始地址
stack_size用户态栈的大小,通常设置为0
parent_tidptr和child_tidptr 指向用户空间中地址的两个指针,分别指向父、子进程的ID
tls传递线程本地存储

常见的标志位,选取其中常用的几个

标志位含义
CLONE_VM    父、子进程共享进程地址空间
CLONE_FS父、子进程共享文件系统信息
CLONE_FILES父、子进程共享打开的文件
CLONE_SIGHAND父、子进程共享信号处理函数以及被阻塞的信号
CLONE_VFORK在创建子进程时启用Linux内核的完成量机制,wait_for_completion会使父进程进入睡眠状态,直到子进程调用execve或exit释放内存
CLONE_IO复制I/O上下文
CLONE_PTRACE父进程被跟踪、子进程也会被跟踪

_do_fork()函数主要是调用copy_process函数来创建子进程的task_struct数据结构,以及从父进程复制必要的内容到子进程的task_struct数据结构中,完成子进程的创建,如下图所示

第一步、检查子进程是否允许被跟踪

如果父进程正在被跟踪(即current->ptrace不为0时),检查debugger程序是否想跟踪子进程,并且子进程不是内核进程(CLONE_UNTRACED未设置)那么就设置CLONE_PTRACE标志,即子进程也被跟踪

if (!(clone_flags & CLONE_UNTRACED)) {if (clone_flags & CLONE_VFORK)trace = PTRACE_EVENT_VFORK;else if ((clone_flags & CSIGNAL) != SIGCHLD)trace = PTRACE_EVENT_CLONE;elsetrace = PTRACE_EVENT_FORK;if (likely(!ptrace_event_enabled(current, trace)))trace = 0;
}

第二步、复制进程描述符,返回的是新的进程描述符的地址

调用copy_process函数创建一个新的子进程,如果成功就返回子进程的task_struct

p = copy_process(clone_flags, stack_start, stack_size,child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
add_latent_entropy();

第三步、初始化完成量

对于vfork创建的子进程,首先要保证子进程先运行,子进程调用exec()或exit()之后,才可以调度,运行父进程,因此这里使用了一个vfork_done的完成量达到该目的。

struct completion vfork;
struct pid *pid;trace_sched_process_fork(current, p);
//1. 由子进程的task_struct数据结构来获取PID
pid = get_task_pid(p, PIDTYPE_PID);
//2. pid_vnr获取虚拟的PID,即从当前命令空间内部看到的PID
nr = pid_vnr(pid);if (clone_flags & CLONE_PARENT_SETTID)put_user(nr, parent_tidptr);
//3. init_completion初始化完成量
if (clone_flags & CLONE_VFORK) {p->vfork_done = &vfork;init_completion(&vfork);get_task_struct(p);
}

第四步、唤醒新进程

wake_up_new_task函数用于唤醒新创建的进程,也就是把进程加入就绪队列里并接受调度、运行。

    wake_up_new_task(p);//将子进程加入到调度器中,为其分配 CPU,准备执行1
第五步、等待子进程完成

对于使用vfork(),wait_for_vfork_done函数等待子进程调用exec()或exit()

/* forking complete and child started to run, tell ptracer */
if (unlikely(trace))ptrace_event_pid(trace, pid);if (clone_flags & CLONE_VFORK) {if (!wait_for_vfork_done(p, &vfork))ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}

第六步、返回子进程的ID

在父进程返回用户空间时,其返回子进程的ID,子进程返回用户空间时,其返回值为0。

do_fork函数执行后就存在两个进程,而且每个进程都会从 _do_fork函数的返回处执行。程序可以通过fork的返回值来区分父、子进程

父进程,返回新创建的子进程的ID
子进程,返回0
其处理流程如下图所示

2. copy_process函数分析

copy_process函数使fork的核心函数,它会创建新进程的描述符,以及新进程执行所需要的其他数据结构,我们主要来看看这个具体做了些什么?

第一步、标志位检查

// 1. CLONE_NEWS表明父子进程不共享mount的命名空间,每个进程可以拥有属于自己的mount空间if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))return ERR_PTR(-EINVAL);
// 2. CLONE_NEWUSER表示子进程要创建新的user命名空间,USER命令空间用于管理USER ID和Group ID的映射,起到隔离的作用if ((clone_flags & (CLONE_NEWUSER|CLONE_FS)) == (CLONE_NEWUSER|CLONE_FS))return ERR_PTR(-EINVAL);/** Thread groups must share signals as well, and detached threads* can only be started up within the thread group.*/
// 3. CLONE_THREAD表示父子进程在同一个线程组里,POSIX标准规定在一个进程的内部,多个线程共享一个PID,但是linux为每个线程和进程都分配了PIDif ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))return ERR_PTR(-EINVAL);/** Shared signal handlers imply shared VM. By way of the above,* thread groups also imply shared VM. Blocking this case allows* for various simplifications in other code.*/
// 4. CLONE_SIGHAND表明父子进程共享相同的信号处理表,CLONE_VM表明父子进程共享内存空间if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))return ERR_PTR(-EINVAL);/** Siblings of global init remain as zombies on exit since they are* not reaped by their parent (swapper). To solve this and to avoid* multi-rooted process trees, prevent global and container-inits* from creating siblings.*/
// 5. CLONE_PARENT表明新创建的进程是兄弟关系,而不是父子关系,他们拥有相同的父进程if ((clone_flags & CLONE_PARENT) &&current->signal->flags & SIGNAL_UNKILLABLE)return ERR_PTR(-EINVAL);/** If the new process will be in a different pid or user namespace* do not allow it to share a thread group with the forking task.*/
// 6. CLONE_NEWPID表明创建一个新的PID命名空间if (clone_flags & CLONE_THREAD) {if ((clone_flags & (CLONE_NEWUSER | CLONE_NEWPID)) ||(task_active_pid_ns(current) !=current->nsproxy->pid_ns_for_children))return ERR_PTR(-EINVAL);}

第二步、分配一个task_struct数据结构

dup_task_struct()为新进程分配一个task_struct数据结构,后续补充这个函数做了些什么?retval = security_task_create(clone_flags);if (retval)goto fork_out;retval = -ENOMEM;p = dup_task_struct(current, node);if (!p)goto fork_out;

第三步、复制父进程

user数据结构中的processes成员记录了该用户的进程数,这里检查进程数是否超过了进程资源的限制RLIMIT_NPROC

    ftrace_graph_init_task(p);rt_mutex_init_task(p);#ifdef CONFIG_PROVE_LOCKINGDEBUG_LOCKS_WARN_ON(!p->hardirqs_enabled);DEBUG_LOCKS_WARN_ON(!p->softirqs_enabled);
#endifretval = -EAGAIN;
// 1. 检查进程数是否超过限制,由操作系统定义if (atomic_read(&p->real_cred->user->processes) >=task_rlimit(p, RLIMIT_NPROC)) {if (p->real_cred->user != INIT_USER &&!capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))goto bad_fork_free;}current->flags &= ~PF_NPROC_EXCEEDED;
//2. 复制父进程retval = copy_creds(p, clone_flags);if (retval < 0)goto bad_fork_free;

第四步、初始化task_stcut//初始化子进程描述符中的list_head数据结构和自旋锁,并为与挂起信号、定时器及时间统计表相关的几个字段赋初值。

    delayacct_tsk_init(p);    /* Must remain after dup_task_struct() */p->flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER);p->flags |= PF_FORKNOEXEC;INIT_LIST_HEAD(&p->children);INIT_LIST_HEAD(&p->sibling);rcu_copy_process(p);p->vfork_done = NULL;spin_lock_init(&p->alloc_lock);init_sigpending(&p->pending);p->utime = p->stime = p->gtime = 0;p->utimescaled = p->stimescaled = 0;prev_cputime_init(&p->prev_cputime);

第五步、初始化进程调度相关的数据结构

sched_fork函数初始化与进程调度相关的数据结构,调度实体用sched_entity数据结构来抽象,每个进程或线程都是一个调度实体。

    /* Perform scheduler related setup. Assign this task to a CPU. */retval = sched_fork(clone_flags, p);if (retval)goto bad_fork_cleanup_policy;
  • 调用__sched_fork,在这里面将on_rq设为0,初始化sched_entity,将里面的exec_start、sum_exec_runtime、prev_exec_runtime、vruntime都设为0。这几个变量涉及进程的实际运行时间和虚拟运行时间。是否到时间应该被调度了,就靠它们几个
  • 设置进程的状态: p->state= TASK_NEW;
  • 初始化优先级: prio、normal_prio、static_prio
  • 设置调度类,如果是普通进程,就设置为p->sched_class = &fair_sched_class;
  • 调用调度类的task_fork函数,对于CFS来讲,就是调用task_fork_fair。在这个函数里,
    • 先调度员update_curr,对当前的进程进行统计量更新
    • 然后把子进程和父进程的vruntime设成一样
    • 最后调用place_entity,初始化sched_entity。 这里有一个变量,sysctl_sched_child_runs_first,可以设置父进程和子进程谁先运行。如果设置了子进程先运行,即便两个进程的vruntime一样,也要把子进程的sched_entity放在前面,然后调用resched_curr,标记当前运行的进程TIF_NEED_RESCHED,也就是说,把父进程设置为应该被调度,这样下次调度的时候,父进程会被子进程抢占。

第六步、初始化task_struct结构的其他数据结构

    retval = perf_event_init_task(p);if (retval)goto bad_fork_cleanup_policy;retval = audit_alloc(p);if (retval)goto bad_fork_cleanup_perf;/* copy all the process information */shm_init_task(p);retval = copy_semundo(clone_flags, p);if (retval)goto bad_fork_cleanup_audit;retval = copy_files(clone_flags, p);if (retval)goto bad_fork_cleanup_semundo;retval = copy_fs(clone_flags, p);if (retval)goto bad_fork_cleanup_files;retval = copy_sighand(clone_flags, p);if (retval)goto bad_fork_cleanup_fs;retval = copy_signal(clone_flags, p);if (retval)goto bad_fork_cleanup_sighand;retval = copy_mm(clone_flags, p);if (retval)goto bad_fork_cleanup_signal;retval = copy_namespaces(clone_flags, p);if (retval)goto bad_fork_cleanup_mm;retval = copy_io(clone_flags, p);if (retval)goto bad_fork_cleanup_namespaces;retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);if (retval)goto bad_fork_cleanup_io;if (pid != &init_struct_pid) {pid = alloc_pid(p->nsproxy->pid_ns_for_children);if (IS_ERR(pid)) {retval = PTR_ERR(pid);goto bad_fork_cleanup_thread;}}

copy_files 主要用于复制一个进程打开的文件信息。这些信息用一个结构 files_struct 来维护,每个打开的文件都有一个文件描述符。在 copy_files 函数里面调用 dup_fd,在这里面会创建一个新的 files_struct,然后将所有的文件描述符数组 fdtable 拷贝一份。
copy_fs 主要用于复制一个进程的目录信息。这些信息用一个结构 fs_struct 来维护。一个进程有自己的根目录和根文件系统 root,也有当前目录 pwd 和当前目录的文件系统,都在 fs_struct 里面维护。copy_fs 函数里面调用 copy_fs_struct,创建一个新的 fs_struct,并复制原来进程的 fs_struct。
copy_sighand 会分配一个新的 sighand_struct。这里最主要的是维护信号处理函数,在 copy_sighand 里面会调用 memcpy,将信号处理函数 sighand->action 从父进程复制到子进程。
init_sigpending 和 copy_signal 用于初始化,并且复制用于维护发给这个进程的信号的数据结构。copy_signal 函数会分配一个新的 signal_struct,并进行初始化。
进程都自己的内存空间,用 mm_struct 结构来表示。copy_mm 函数中调用 dup_mm,分配一个新的 mm_struct 结构,调用 memcpy 复制这个结构。dup_mmap 用于复制内存空间中内存映射的部分。前面讲系统调用的时候,我们说过,mmap 可以分配大块的内存,其实 mmap 也可以将一个文件映射到内存中,方便可以像读写内存一样读写文件,这个在内存管理那节我们讲。
copy_namespace函数复制父进程的命名地址空间
copy_io函数复制父进程与I/O相关的内容
copy_thread_tls函数复制父进程的内核堆信息
第七步、分配ID

开始分配 pid,设置 tid,group_leader,并且建立进程之间的亲缘关系。

   p->pid = pid_nr(pid);if (clone_flags & CLONE_THREAD) {p->exit_signal = -1;p->group_leader = current->group_leader;p->tgid = current->tgid;} else {if (clone_flags & CLONE_PARENT)p->exit_signal = current->group_leader->exit_signal;elsep->exit_signal = (clone_flags & CSIGNAL);p->group_leader = p;p->tgid = p->pid;}

pid_nr分配一个全局的PID,这个全局的PID是从init进程的命名空间的家督来看,是一个虚拟的PID
设置group_leader和TGID
第八步、返回进程描述符

分配task_struct,并完成各项的初始化后,就返回子进程的描述符。

到此,copy_process函数的处理流程完毕,其处理流程如下图所示

3. wake_up_new_task唤醒新进程流程

用copy_process来拷贝出一个新的进程pcb,然后调用wake_up_new_task将新的进程放入运行队列并唤醒该进程。同时新任务刚刚建立,有没有机会抢占别人,获得 CPU 呢?

void wake_up_new_task(struct task_struct *p)
{struct rq_flags rf;struct rq *rq;
//1. 需要将进程的状态设置为 TASK_RUNNINGraw_spin_lock_irqsave(&p->pi_lock, rf.flags);p->state = TASK_RUNNING;
#ifdef CONFIG_SMP/** Fork balancing, do it here and not earlier because:*  - cpus_allowed can change in the fork path*  - any previously selected cpu might disappear through hotplug** Use __set_task_cpu() to avoid calling sched_class::migrate_task_rq,* as we're not fully set-up yet.*/
//2.这个函数会根据新创建的这个线程所属的调度类去执行不同的select_task_rq。__set_task_cpu(p, select_task_rq(p, task_cpu(p), SD_BALANCE_FORK, 0));
#endifrq = __task_rq_lock(p, &rf);post_init_entity_util_avg(&p->se);activate_task(rq, p, 0);p->on_rq = TASK_ON_RQ_QUEUED;trace_sched_wakeup_new(p);check_preempt_curr(rq, p, WF_FORK);
#ifdef CONFIG_SMPif (p->sched_class->task_woken) {/** Nothing relies on rq->lock after this, so its fine to* drop it.*/lockdep_unpin_lock(&rq->lock, rf.cookie);p->sched_class->task_woken(rq, p);lockdep_repin_lock(&rq->lock, rf.cookie);}
#endiftask_rq_unlock(rq, p, &rf);
}

activate_task 函数中会调用 enqueue_task,就会涉及到调度相关的流程,该内容在调度中进行学习。

static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
{update_rq_clock(rq);if (!(flags & ENQUEUE_RESTORE))sched_info_queued(rq, p);p->sched_class->enqueue_task(rq, p, flags);
}

子进程创建后,肯定要加入到CPU的执行队列中,这样才有可能被执行,这是调用wake_up_new_task()来实现的。这是调度器与进程创建的第二个逻辑交互时机,内核会调用调度器类的task_new函数(sched_class结构中),将新进程加入到相应类的就绪队列。
至此,创建用户进程的过程就完成了。其主要的要点如下:

每个进程需要有一个内核栈,不管是4K还是8KB,这个内核栈需要包含两部分,一个是task_struct数据结构,另外一个是内核栈
继承父进程的task_struct数据结构,然后进行调整
设置进程空间的栈
拷贝父进程的进程地址空间给子进程
将子进程唤醒,设置到就绪队列中,初始化调度相关的,然后等待调度器进行调度

4. 总结

fork, vfork和clone的系统调用的入口地址分别是sys_fork, sys_vfork和sys_clone, 而他们的定义是依赖于体系结构的, 而他们最终都调用了_do_fork,在_do_fork中通过copy_process复制进程的信息,调用wake_up_new_task将子进程加入调度器中,其主要的工作内容如下:

copy_process()函数会做fork的大部分事情,它主要完成讲父进程的运行环境复制到新的子进程,比如信号处理、文件描述符和进程的代码数据等,初始化进程控制块中的所有成员,其处理流程如下

wake_up_new_task()。计算此进程的优先级和其他调度参数,将新的进程加入到进程调度队列并设此进程为可被调度的,以后这个进程可以被进程调度模块调度执行。

进程创建内存管理

 

我们先来看下Linux内存布局,此图比我之前写的那篇文章写的布局更详细

在linux中,每一个进程都被抽象为task_struct结构体,称为进程描述符,存储着进程

各方面的信息;例如打开的文件,信号以及内存等等;然后task_struct的一个属性mm_struct管理着进程的所有虚拟内存,称为内存描述符。在mm_struct结构体中,存储着进程各个内存段的开始以及结尾,如上图所示;这个进程使用的物理内存,即常驻内存RSS页数,这个内存使用的虚拟地址空间VSZ页数,还有这个进程虚拟内存区域集合和页表。

从上面这个图可以看出,进程是有代码段Text segment,数据段(已初始化的全局,静态变量),BSS段(未初始化的全局,静态变量),堆,内存映射区以及栈;

每一块虚拟内存区(VMA)都是由一块连续的虚拟地址组成,这些地址从不覆盖。一个vm_area_struct实例描述了一块内存区域,包括这块内存区域的开始以及结尾地址;flags标志决定了这块内存的访问权限和行为;vm_file决定这块内存是由哪个文件映射的,如果没有文件映射,则这块内存为匿名的(anonymous)。上述图中提到的每个内存段,都对应于一个vm_area_struct结构。如下图所示

上图即为/bin/gonzo进程的内存布局。程序的二进制文件映射到代码段和数据段,代码段为只读只执行,不可更改;全局以及静态的未初始化的变量映射到BSS段,为匿名映射,堆和栈也是匿名映射,因为没有相应的文件映射;内存映射区可以映射共享库,映射文件以及匿名映射,所以这块内存段可以是文件映射也可以是匿名映射。而且不同的文件,映射到不同的vm_area_struct区。

这些vm_area_struct集合存储在mm_struct中的一个单向链表和红黑树中;当输出/proc/pid/maps文件时,只需要遍历这个链表即可。红黑树主要是为了快速定位到某一个内存块,红黑树的根存储在mm_rb域。

之前介绍过,线性地址需要通过页表才能转换为物理地址。每个进程的内存描述符也保存了这个进程页表指针pgd,每一块虚拟内存页都和页表的某一项对应。

虚拟内存是不存储任何数据的,它只是将地址空间映射到物理内存。物理内存有内核伙伴系统分配,如果一块物理内存没有被映射,就可以被伙伴系统分配给虚拟内存。刚分配的物理内存叶框可能是匿名的,存储进程数据,也可能是也缓存,存储文件或块设备的数据。一块虚拟内存vm_area_struct块是由连续的虚拟内存页组成的,而这些虚拟内存块映射的物理内存却不一定连续,如下图所示:

如上图所示,有三个页映射到物理内存,还有两个页没有映射,所以常驻内存RSS为12kb,而虚拟内存大小为20kb。对于有映射到物理内存的三个页的页表项PTE的Present标志设为1,而两个没有映射物理内存的虚拟内存页表项的Present位清除。所以这时访问那两块内存,则会导致异常缺页。

vma就像应用程序和内核的一个契约。当应用程序申请内存或者文件映射时,内核先响应这个请求,分配或更新虚拟内存;但是这些虚拟内存并没有映射到真实的物理内存。而是等到内存访问产生一个内存异常缺页时才真正映射物理内存。即当访问没有映射的虚拟内存时,由于页表项的Present位没有被设置,所以此时会产生一个缺页异常。vma记录和页表项两个在解决内存缺页,释放内存以及内存swap out都起着重要的作用。下面图展示了上述情况:

1、一开始堆中只有8kb的内存,而且都已经映射到物理内存;

2、当调用brk()函数扩展堆时,新的页是没有映射到物理内存的,

3、当处理器需要访问一个地址,而且这个地址在上述刚分配的虚拟内存中,这时产生一个缺页异常;

4、这时进程向伙伴系统申请一页的物理内存,映射到那块虚拟内存上,并添加页表项,设置Present位.

自此,这个内存管理暂时就说到这。总结下:

1、Linux进程的内存布局的每个段都是有一个vm_area_struct,而这个实例是由连续的虚拟内存地址组成;

2、当请求内存时,先是扩展vm_area_struct或者新分配一个vm_area_struct,但是并不映射物理内存,只有等到访问这块内存时,产生缺页异常,内核才分配物理内存。

 在前面的章节中,我们主要关注的是内核的虚拟地址空间的管理。从本节开始,我们重点关注管理用户空间的方法,其中由于种种原因,这个比内核地址空间管理更复杂。本节主要围绕以下内容:

用户进程的虚拟地址空间是Linux的一个重要抽象,它向每个运行进程提供了同样的系统,每个应用程序都有自身的地址空间,与所有的应用程序分割开,不会干扰到其他进程内存的内容。
在内核的虚拟地址空间中,只有很少的段可用于各个用户空间进程,这些段彼此有一定的距离,内核需要一些数据结构来有效的管理这些分布的段
地址空间中只有极少的一部分与物理页直接关联,不经常使用的部分,仅当必要时与页帧关联
内核无法信任用户进程,所以各个操作系统用户地址空间的操作伴随着各种检查,以确保程序的权限不会超出应有的限制,进而危及到系统的稳定性和安全性
用户空间的内存分配方法
1. 进程虚拟地址空间
理论上,64Bit地址支持访问空间是[0, 0xFFFF FFFF FFFF FFFF],而实际上现有的应用程序都不会用这么大的地址空间,而现在ARM64芯片上也不支持访问这么大的地址空间,现有的架构最大支持访问48bit的地址空间。而对于进程有用户态和内核态,同样进程地址空间包括用户地址空间和内核地址空间,用户态访问用户地址空间。对于各个进程的虚拟地址空间起始于地址0,延伸到TASK_SIZE - 1,其上是内核地址空间。

在ARM32系统上,地址空间范围为4GB,总的地址空间通常按照3:1划分,各个用户空间进程可用的部分是3GB

在ARM64系统上,64位虚拟地址中,并不是所有位都用上,除了高16位用于区分内核空间和用户空间外,有效位的配置可以是:36, 39, 42, 47。这可决定Linux内核中地址空间的大小。比如以采用4KB的页,4级页表,虚拟地址为48位的系统为例(从ARMv8.2架构开始,支持虚拟地址和物理地址的大小最多为52位),其虚拟地址空间的范围为256TB ,按照1:1的比例划分,内核空间和用户空间各占128TB。

对于用户程序只能访问整个地址空间的下半部分,不能访问内核部分。同时无论当前哪个用户进程处于活动状态,虚拟地址空间内核部分的内容总是相同的。

1.1 进程地址空间的布局
一个进程通常由加载一个elf文件启动,而elf文件是由若干segments组成的,同样的,进程地址空间也由许多不同属性的segments组成。虚拟地址空间中包含了若干区域,其分布方式特定于体系结构,但所有的方法都有下列共同的特点,如下图所示


text段:包含了当前运行进程的二进制代码,其起始地址在IA32体系中中通常为0x08048000,在IA64体系中通常为0x0000000000400000

data段:包含程序显式初始化的全局变量和静态变量,即已初始化且初值不为0的全局变量(也包括静态全局变量)和静态局部变量,这些数据是在程序真正运行前就已经确定的数据,所以可以提前加载到内存保存好。

bss段:未初始化的全局变量和静态变量,这些变量的值是在程序真正运行起来并为其赋值后才能确定的,所以程序加载之初,只需要记录它的内存地址和所需大小。出于历史原因,这段空间也称为 BSS 段。

heap段:存储动态分配的内存中的数据,堆用于存储那些生存期与函数调用无关的数据。如用系统调用 malloc 申请的内存便在堆上,这些申请的内存在不需要时必须手动释放,否则便会出现内存泄漏。

stack段:用于保存局部变量和实现函数/过程调用的上下文,它们的大小都是会在进程运行过程中发生变化的,因此中间留有空隙,heap向上增长,stack向下增长,因为不知道heap和stack哪个会用的多一些,这样设置可以最大限度的利用中间的空隙空间。进程每调用一次函数,都将为该函数分配一个栈帧,栈帧中保存了该函数的局部变量、参数值和返回值。

文件映射段:这个段比较特殊,是mmap()系统调用映射出来的。mmap映射的大小也是不确定的。3GB的虚拟地址空间已经很大了,但heap段, stack段,mmap段在动态增长的过程还是有重叠(碰撞)的可能。为了避免重叠发生,通常将mmap映射段的起始地址选在TASK_SIZE/3(也就是1GB)的位置。如果是64位系统,则虚拟地址空间更加巨大,几乎不可能发生重叠。

我们以最简单的Helloworld程序为例,其内存空间布局如下图所示

1.2 建立布局
那我们了解了进程在运行过程中的内存空间分布情况,那么如何建立起这种内存空间呢?首先,在Linux系统中运行一个可执行的ELF文件时,内核首先需要识别这个文件,然后解析并装载它以构建进程的内存空间,最后切换到新的进程来运行。

首先,我们来看一下elf文件的格式,Section头表包含了描述文件Sections的信息。每个Section在这个表中有一个入口,每个入口给出了该Section的名字,大小等信息。同时可执行文件有一个头部,里面有一些关键信息,Entry point Address,入口地址,即程序的起点,0x8048300,后面有一些代码,数据

当我们在linux的shell命令中执行某个elf可执行文件的时候,linux系统是如何装载该ELF并执行的呢?其主要是以下几个步骤:

创建新进程:首先在用户层面,shell进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用来执行指定的ELF。

检查可执行文件的类型:当进入execve()系统调用之后,Linux内核就开始真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),会执行do_execve()查找被执行的文件,如果找到文件,则读取文件的前128个字节,通过来判断该执行文件是哪一种elf文件,例如a.out,java程序,以及脚本开头的文件。

搜索匹配的装载处理过程:do_execve()读取128个字节的文件头部后,调用search_binary_handle()去搜索和匹配合适的可执行文件,最常见的可执行文件及处理过程如下

ELF可执行文件:load_elf_binary
a.out 可执行文件:load_aout_library
可执行脚本程序:load_script()
在装载的过程中,对于可执行文件,应该创建对应的.text段、.data段、stack段等。在Linux中,每个段都用一个vm_area_strcutvm_area_strcut结构体表示,vma是通过一个双向链表串起来,现存的vma按照起始地址依次递增被归入链表中,每个vma是这个链表的一个节点,首先我们来看一个进程有一个struct mm_struct用来描述进程的内存信息

struct mm_struct {
    //指向线性区对象的链表头
    struct vm_area_struct * mmap;        /* list of VMAs */
    //指向线性区对象的红黑树
    struct rb_root mm_rb;
    //指向最后一个引用的线性区对象
    struct vm_area_struct * mmap_cache;    /* last find_vma result */
    //在进程地址空间中搜索有效线性地址区间的方法
    unsigned long (*get_unmapped_area) (struct file *filp,
                unsigned long addr, unsigned long len,
                unsigned long pgoff, unsigned long flags);
    //释放线性区时调用的方法
    void (*unmap_area) (struct vm_area_struct *area);
    // 标识第一个分配的匿名线性区或者是文件内存映射的线性地址
    unsigned long mmap_base;        /* base of mmap area */
    //内核从这个地址开始搜索进程地址空间中线性地址的空闲区间
    unsigned long free_area_cache;        /* first hole */
    //指向页表
    pgd_t * pgd;
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
对于mmap指向的vm_area_struct,其定义如下:

struct vm_area_struct {
    unsigned long vm_start;        //虚拟内存空间的首地址
    unsigned long vm_end;        //虚拟内存空间的尾地址
    //VMA链表的下一个成员和上级成员,进程VMA连接成一个链表
    struct vm_area_struct *vm_next, *vm_prev;
    //将本VMA作为一个节点加入到红黑树中,每个进程的struct mm_struct都有一颗这样的红黑树mm_rb
    struct rb_node vm_rb;
    unsigned long rb_subtree_gap;
    //指向该VMA所属的进程struct mm_struct数据结构
    struct mm_struct *vm_mm;    /* The address space we belong to. */
    pgprot_t vm_page_prot;        /* Access permissions of this VMA. */
    unsigned long vm_flags;        /* Flags, see mm.h. */
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
vm_area_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm_area_struct中获得。mmap函数就是要创建一个新的vm_area_struct结构,并将其与文件的物理磁盘地址相连。

至此,我们可以看出,虚拟内存即为由一个个vm_area_struct结构体,通过链表组装起来的空间,其示意图如下图所示


用户进程拥有用户空间的地址,其可以通过malloc和mmap等函数来申请内存,malloc和mmap等函数的实现都是基于进程线性区描述struct vm_erea_struct,内核管理进程地址空间的数据结构struct vm_erea_struct,简称VMA。

对于每个进程的内存描述符mm_struct,都有各自的VMA,通过mm->mmap链表将所有的VMA管理起来,同时会记录到mm->mm_rb的红黑树,用于高速查找合并VMA等操作。

2. 虚拟内存区域的表示
先来说说task_struct,task_struct是一个结构体,这个结构体非常的庞大,linux下用它来完整的描述一个进程的所有信息。在每装载一个进程的时候,内核就会帮我们去创建一个新的task_struct结构体。然后我们知道一个每一个独立的进程都有自己独立的虚拟空间,所以,在task_struct结构体里会有一个struct mm_struct *mm成员,这个mm成员就是用来描述和管理进程的虚拟空间的。由上图可知,每个区域通过一个vm_eara_struct实例描述,进程的各区域按照以下两种方式排序

在一个双链表上(开始于mm_struct->mmap)

在一个红黑树上,跟节点位于mm_rb

总结来说,简单的理解这三者的关系就是task_struct结构体包含了一个mm_sturcut结构体成员,mm_struct结构体包含了一个vm_area_struct结构体成员mmap,然后这个mmap成员指向一个VMA链表,管理所有的VMA。

用户虚拟地址空间中的每个区域由开始和结束地址描述,现存的区域按照起始地址以递增次序被归入链表中。扫描链表找到与特定地址关联的区域,在有大量区域时是非常低效的操作。因此vm_eara_struct的各个实例可以通过红黑树管理,可以显著加快扫描速度。

3. 总结
当一个进程要运行起来需要以下的内存结构:

用户态:

代码段、全局变量、BSS
函数栈

内存映射区
内核态:

内核的代码、全局变量、BSS
内核数据结构例如 task_struct
内核栈
内核中动态分配的内存
对于64位的系统,其进程运行状态如下图所示:

Linux 为虚拟内存不同的段,提供了不同的数据结构来描述:

在 Linux 内核眼中所有的进程、线程都是 task 都适用 task_struck 描述。

task_struck 数据结构中的 mm 字段(mm_struct 类型)描述了进程或者线程用户态的内存信息;

mm_struct 中有映射页的统计信息(总页数, 锁定页数, 数据/代码/栈映射页数等)以及各区域地址

mm_struct 维护着 vm_area_struct 的链表,每个链表节点都描述了用户空间虚拟内存的布局划分:

4. 参考文档

对于进程,除了0号进程,其他的所有进程(无论是内核线程还是普通线程)都是通过fork出来的,而创建进程是在内核中完成的

要么在内核空间直接创建出所谓的内核线程
要么是应用空间通过fork/clone/vfork这样的系统调用进入内核,再内核空间创建
同上一章,我们完成的分析了fork的整个过程,fork分为两部分,一部分是初始化进程控制块,另外一部分是进程管理部分。本章的重点学习以下内容

子进程如何构建自己的内存管理
父子进程如何共享地址空间
写时复制如何发生
1. 写时复制技术
在传统的unix操作系统中,创建新建成时就会复制父进程所拥有的所有资源,这样进程的创建就变的很低效。其原因如下

每次创建子进程时,都要把父进程的进程地址空间中的内容复制到子进程,但是子进程甚至不用父进程的资源
子进程调用execve()系统调用之后,可能和父进程分道扬镳
所以现在的操作系统都采用写时复制(COW,Copy on Write)技术进行优化,其原理如下

父进程在创建子进程的时,不需要复制进程地址空间的内容到子进程,只需要复制父进程的进程地址空间的页表到子进程,并将页面属性修改为只读,这样父、子进程就共享相同的物理内存。
当父、子进程中有一方需要修改某个物理页面的内容,触发写保护的缺页异常,然后才复制共享页面的内容,从而让父、子进程拥有各自的副本
也就是说,进程地址空间以只读的方式共享,当需要写入时,才发生复制

在采用写时复制技术的Linux内核中,用fork函数创建一个新进程的开销就变得很小,免去了复制父进程整个进程地址空间导致的巨大开销,现在只需要复制父进程页表就可以了。


2. copy的内存管理
2.1 内存初始化

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{struct mm_struct *mm, *oldmm;int retval;tsk->min_flt = tsk->maj_flt = 0;tsk->nvcsw = tsk->nivcsw = 0;
#ifdef CONFIG_DETECT_HUNG_TASKtsk->last_switch_count = tsk->nvcsw + tsk->nivcsw;
#endif//初始化task的mm_struct和VMA为空tsk->mm = NULL;tsk->active_mm = NULL;/** Are we cloning a kernel thread?** We need to steal a active VM for that..*/oldmm = current->mm;        //current宏表明当前进程,即父进程if (!oldmm)        //如果父进程使一个没有进程地址空间的内核线程,不需要为子进程做内存复制,直接退出return 0;/* initialize the new vmacache entries */vmacache_flush(tsk);//如果调用vfork创建子进程,那么CLONE_VM标志位就会被置位,因此子进程进程的mm直接指向父进程的内存描述符if (clone_flags & CLONE_VM) {atomic_inc(&oldmm->mm_users);mm = oldmm;goto good_mm;}//如果CLONE_VM没有置位,那么调用dump_mm来复制父进程的进程地址空间retval = -ENOMEM;mm = dup_mm(tsk);if (!mm)goto fail_nomem;good_mm:tsk->mm = mm;tsk->active_mm = mm;return 0;fail_nomem:return retval;
}

该函数比较简单,其主要做了以下几件事情

如果使内核线程,也就是当前进程地址空间为空,就不需要为子进程做内存复制,直接退出
在创建的时候,会根据fork参数的clone_flags来决定,如果是CLONE_VM标志位就会被置位,子进程的mm指针指向父进程的内存描述符的mm即可
如果CLONE_VM没有被置为,那么调用dump_mm来复制父进程的进程地址空间
dum_mm函数实现也在fork.c文件中,实现也比较简单

static struct mm_struct *dup_mm(struct task_struct *tsk)
{struct mm_struct *mm, *oldmm = current->mm;int err;
//1.通过allocate_mm分配属于进程自己的mm_struct结构来管理自己的地址空间mm = allocate_mm();if (!mm)goto fail_nomem;memcpy(mm, oldmm, sizeof(*mm));
//2.通过mm_init来初始化mm_struct中相关成员if (!mm_init(mm, tsk, mm->user_ns))goto fail_nomem;
//3.通过dup_mmap来复制父进程的地址空间err = dup_mmap(mm, oldmm);if (err)goto free_pt;mm->hiwater_rss = get_mm_rss(mm);mm->hiwater_vm = mm->total_vm;if (mm->binfmt && !try_module_get(mm->binfmt->module))goto free_pt;return mm;free_pt:/* don't put binfmt in mmput, we haven't got module yet */mm->binfmt = NULL;mmput(mm);fail_nomem:return NULL;
}



分配mm_struct结构就不需要赘述,我们先看下mm_init,其函数如下

static struct mm_struct *mm_init(struct mm_struct *mm, struct task_struct *p,struct user_namespace *user_ns)
{
// 1. 初始化mmmm->mmap = NULL;mm->mm_rb = RB_ROOT;mm->vmacache_seqnum = 0;atomic_set(&mm->mm_users, 1);atomic_set(&mm->mm_count, 1);init_rwsem(&mm->mmap_sem);INIT_LIST_HEAD(&mm->mmlist);mm->core_state = NULL;atomic_long_set(&mm->nr_ptes, 0);mm_nr_pmds_init(mm);mm->map_count = 0;mm->locked_vm = 0;mm->pinned_vm = 0;memset(&mm->rss_stat, 0, sizeof(mm->rss_stat));spin_lock_init(&mm->page_table_lock);mm_init_cpumask(mm);mm_init_aio(mm);mm_init_owner(mm, p);RCU_INIT_POINTER(mm->exe_file, NULL);mmu_notifier_mm_init(mm);clear_tlb_flush_pending(mm);
#if defined(CONFIG_TRANSPARENT_HUGEPAGE) && !USE_SPLIT_PMD_PTLOCKSmm->pmd_huge_pte = NULL;
#endifmm_init_uprobes_state(mm);if (current->mm) {mm->flags = current->mm->flags & MMF_INIT_MASK;mm->def_flags = current->mm->def_flags & VM_INIT_DEF_MASK;} else {mm->flags = default_dump_filter;mm->def_flags = 0;}
//2,分配一个进程私有的page页,当需要va->pa转换的时候,查找属于当前进程的pgd表项if (mm_alloc_pgd(mm))goto fail_nopgd;
//3. 设置了mm->context.id为0,当进程调度的时候进行地址空间切换,如果mm->context.id为0就为进程分配新的ASIDif (init_new_context(p, mm))goto fail_nocontext;mm->user_ns = get_user_ns(user_ns);return mm;
}

每个进程在创建的时都会分配一级页表,并且内存描述符中一个pdg的成员指向这个进程的一级页表的基地址。当进程初始化完成后,需要转换,进程切换的时候,会使用tsk->mm->pgd指向的页表作为base来进程页表中遍历,对于ARM64架构来说,他们由两个页表的基地址寄存器ttbr0_el1和ttbr1_el0。

讲完mm_init相关的内容,接着返回dum_mmap,dup_mmap函数参数中,mm表示新进程的mm_struct数据结构,oldmm表示父进程的mm_struct数据结构。该函数的主要作用是遍历父进程中所有的VMA,然后复制父进程VMA中对应的PTE到子进程的VMA对应的PTE中。注意,只是复制PTE,并不是复制VMA对应页面的内容。

static __latent_entropy int dup_mmap(struct mm_struct *mm,struct mm_struct *oldmm)
{down_write_killable(&oldmm->mmap_sem);for (mpnt = oldmm->mmap; mpnt; mpnt = mpnt->vm_next) {INIT_LIST_HEAD(&tmp->anon_vma_chain);retval = vma_dup_policy(mpnt, tmp);if (retval)goto fail_nomem_policy;tmp->vm_mm = mm;if (anon_vma_fork(tmp, mpnt))goto fail_nomem_anon_vma_fork;__vma_link_rb(mm, tmp, rb_link, rb_parent);rb_link = &tmp->vm_rb.rb_right;rb_parent = &tmp->vm_rb;mm->map_count++;retval = copy_page_range(mm, oldmm, mpnt);}up_write(&mm->mmap_sem);
}

由于后续会修改父进程的进程地址空间,因此要给父进程加上一个写类型的信号量
通过fork循环遍历父进程中所有的VMA,进程中所有的VMA都会添加到内存描述符的mmap成员指向的链表中
vma_dup_policy为子进程创建一个VMA,子进程VMA中有一个链表aon_vma_chain,用于存放aon_vma_chain数据结构,用在RMAP机制中
anon_vma_fork函数创建属于子进程的aon_vma数据结构,并使用aon_vma_chain来实现父子进程VMA的链接
__vma_link_rb把刚才创建的VMA插入子进程的mm
copy_page_range复制父进程VMA的页表到子进程页表中
对于每一个vma都调用copy_page_range,此函数会遍历vma中每一个虚拟页,然后拷贝父进程的页表到子进程(虚拟页对应的页表存在的话),这里主要是页表遍历的代码,从pgd->copy_page_range->copy_pud_range->copy_pmd_range->copy_pte_range

static int copy_pte_range(struct mm_struct *dst_mm, struct mm_struct *src_mm,pmd_t *dst_pmd, pmd_t *src_pmd, struct vm_area_struct *vma,unsigned long addr, unsigned long end)
{do {/** We are holding two locks at this point - either of them* could generate latencies in another task on another CPU.*/if (progress >= 32) {progress = 0;if (need_resched() ||spin_needbreak(src_ptl) || spin_needbreak(dst_ptl))break;}if (pte_none(*src_pte)) {progress++;continue;}entry.val = copy_one_pte(dst_mm, src_mm, dst_pte, src_pte,vma, addr, rss);if (entry.val)break;progress += 8;}
}

我们看的在copy_present_pte函数中,对父子进程的写保护处理,也就是当发现父进程的vma的属性为私有可写的时候,就设置父进程和子进程的相关的页表项为只读。这点很重要,因为这样既保证了父子进程的地址空间的共享(读的时候),又保证了他们有独立的地址空间(写的时候)。

2.2 写时复制发生
ork创建完子进程后,通过复制父进程的页表来共享父进程的地址空间,我们知道对于私有的可写的页,设置了父子进程的相应页表为为只读,这样就为写实复制创造了页表层面上的条件。当父进程或者子进程,写写保护的页时触发访问权限异常:

 ...   //处理器架构处理

 do_page_fault                       // arch/arm64/mm/fault.c
->  __do_page_fault->  handle_mm_fault -> handle_pte_fault      //mm/memory.c->  if (vmf->flags & FAULT_FLAG_WRITE) {          if (!pte_write(entry))                return do_wp_page(vmf);       entry = pte_mkdirty(entry);           }         


这一章的详细内容,请参考linux内存管理笔记(三十六)----写时复制

3. 总结
fork的时候会创建内核管理初始化,例如mm_struct, vma等用于描述进程自己的地址空间,然后会创建出进程私有的pgd页,用于页表遍历时填充页表,然后还会拷贝父进程所有的vma,然后就是对于每个vma做页表的拷贝和写保护操作。后面的pud pmd的其他各级页表的创建和填充工作由缺页异常处理来完成。

进程终止与返回

上一章学习了进程的创建,在用户空间可以使用fork接口来创建一个用户进程,或者使用clone接口来创建一个用户线程,它们在内核空间都会调用do_fork函数来实现,但是我们有两个疑问未得到解答

fork接口,它可以是父、子进程都会返回,那么它会返回两次,其中父进程的返回值是子进程的PID,而子进程返回0,这个过程是如何的呢?
子进程第一次返回用户空间时,它的返回在哪里呢?
进程如何完成终止
1. fork的执行过程
当调用_do_fork()函数创建子进程后,子进程会加入到内核的调度器中,在调度器中参与调度。那么子进程在稍后的某一时刻得到调度和执行,因此fork函数也会有两次返回,一次是父进程的返回,另外一次是子进程被调度后执行的返回。

我们以copy_process为例,下面是在里面有一个copy_thread的线程

static __latent_entropy struct task_struct *copy_process(unsigned long clone_flags,unsigned long stack_start,unsigned long stack_size,int __user *child_tidptr,struct pid *pid,int trace,unsigned long tls,int node)
{...retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);...
}

在Linux4.2后增加了CONFIG_HAVE_COPY_THREAD_TLS宏和copy_thread_tls函数,这个函数使一个特定于体系结构的函数,用于复制进程中特定的线程的数据,重要的是填充task_struct->thread的各个成员,其对于ARM64的定义如下:

int copy_thread(unsigned long clone_flags, unsigned long stack_start,unsigned long stk_sz, struct task_struct *p)
{struct pt_regs *childregs = task_pt_regs(p);memset(&p->thread.cpu_context, 0, sizeof(struct cpu_context));fpsimd_flush_task_state(p);//创建子进程时用户进程的情况if (likely(!(p->flags & PF_KTHREAD))) {//将当前寄存器信息复制给子进程*childregs = *current_pt_regs();childregs->regs[0] = 0;//子进程 X0寄存器 0,因此fork 在子进程返回0  *task_user_tls(p) = read_sysreg(tpidr_el0);if (stack_start) {if (is_compat_thread(task_thread_info(p)))childregs->compat_sp = stack_start;elsechildregs->sp = stack_start;        //创建线程时设置用户栈起始地址}if (clone_flags & CLONE_SETTLS)p->thread.tp_value = childregs->regs[3];} else {//处理子进程是内核线程的情况memset(childregs, 0, sizeof(struct pt_regs));childregs->pstate = PSR_MODE_EL1h;//设置子进程的处理器状态为   PSR_MODE_EL1h ,异常等级为el1使用sp_el1             if (IS_ENABLED(CONFIG_ARM64_UAO) &&cpus_have_cap(ARM64_HAS_UAO))childregs->pstate |= PSR_UAO_BIT;p->thread.cpu_context.x19 = stack_start;//设置内核线程执行函数地址p->thread.cpu_context.x20 = stk_sz;//设置传递给函数的参数  }//设置子进程的进程硬件上下文中的PC和SP成员的值p->thread.cpu_context.pc = (unsigned long)ret_from_fork;p->thread.cpu_context.sp = (unsigned long)childregs;ptrace_hw_copy_thread(p);return 0;
}

childregs->regs[0] = 0子进程被调度返回用户空间的时候,fork的返回值为0,这就是为何fork返回值为0表示是子进程的原因
如果创建的是子进程,那么就直接和父进程写时复制方式共享用户栈,而栈不需要在进行设置,直接使用父进程的
进程切换时,子进程的pc和sp,当子进程第一次被调度的时候,从ret_from_fork开始执行指令,栈指针指向childregs,即为设置后pt_regs
在copy_thread函数中会复制父进程struct pt_regs栈的全部内容到子进程,包括描述内核栈上保持的寄存器的全部信息,如X0-X30寄存器,栈指针寄存器,PC寄存器以及PSTATE寄存器信息等。同时还会修改子进程X0的值,该值在返回用户空间时子进程的返回值就是该值。

由此可见,copy_thread这个函数对于进程调度很重要,决定了进程第一次被调度的时候执行哪个代码,决定了fork函数的返回值。pt_regs描述的发生异常的时候保存的现场信息,主要是一些通用寄存器,我们这里称为异常现场:

struct pt_regs {union {struct user_pt_regs user_regs;struct {u64 regs[31];u64 sp;u64 pc;u64 pstate;};};u64 orig_x0;u64 syscallno;u64 orig_addr_limit;u64 unused;    // maintain 16 byte alignment
};

当异常发生,异常的现场,通用寄存器的内容,如X0-X30,sp,pc,pstate会被压入内核栈,通过pt_reg结构来描述。

当异常处理结束时候,需要恢复异常前的现场,会将这些保持的值恢复到通用寄存器中

pu_context描述的是进程调度的时候需要保存的进程上下文,我们这里成为调度现场:

struct cpu_context {unsigned long x19;unsigned long x20;unsigned long x21;unsigned long x22;unsigned long x23;unsigned long x24;unsigned long x25;unsigned long x26;unsigned long x27;unsigned long x28;unsigned long fp;unsigned long sp;unsigned long pc;
};

当进程切换的时候,会将处理器当前需要保持的寄存器保存到前一个进程的tsk的thread.cpu_context中,并将后一个即将要调度的进程的上下文信息从该tsk的thread.cpu_context中恢复到相应的寄存器中,就完成了处理器状态的切换。

所以对该过程pt_regs表明发生异常时,处理器现场,而cpu_context发生调度时,当前进程的处理器现场。

2. 子进程开始执行
子进程时如何开始执行呢?由上面代码,copy_thread函数会使子进程的入口地址PC指向ret_from_fork,该过程主要是通过子进程硬件上下文中PC成员来实现,那么子进程执行就会跳转到该汇编函数中

/** This is how we return from a fork.*/
ENTRY(ret_from_fork)bl    schedule_tailcbz    x19, 1f                // not a kernel threadmov    x0, x20                //赋值内核线程的参数blr    x19                    //执行内核线程函数
1:    get_thread_info tskb    ret_to_user            //返回用户空间
ENDPROC(ret_from_fork)

在第2行中,判断X19寄存器的值是否为空,如果为空,说明这是一个用户进程,则跳转到第5行代码中,调用ret_to_user汇编函数,直接返回用户空间。如果X19寄存器的值不为空,说明这是一个内核线程,直接执行X19寄存器中保存的内核线程回调函数。这个章节在后面进程上下文中单独学习。

3. 进程的终止
系统有源源不断的进程的诞生,同时,也会有进程不断的终止。进程的终止有两种方式

资源地终止,包括调用exit系统调用或者从某个程序的主函数返回
被动地收到终止信号或者异常终止
进程主动终止主要有以下两种途径:

从main函数返回,链接程序会自动添加exit()系统调用
主动调用exit()系统调用
进程被动终止主要有以下途径:

进程收到一个自己不能处理的信号
进程在内核态执行时发生了一个异常
进程收到SIGKILL等终止信号
当一个进程终止时,Linux内核会释放所占用的所有资源,并把这个消息告诉给父进程,而一个进程终止时可能又有以下情况

它先于父进程终止,那么子进程会变成僵尸进程,直到父进程调用wait()才能最终消亡
它也在父进程之后终止,那么Init进程将成为子进程的新父进程
4. 僵尸进程
一个进程通过exit()系统调用终止之后,就会处于僵尸状态。在僵尸状态中,除了进程描述符依然保留外,进程的其他资源已经归还给内核。

Linux内核这么做是为了让系统可以得到子进程的终止原因,父进程可以通过wait()系统调用来获取已终结的子进程的信息之后,内核才会释放子进程的task_strcut数据结构。

但是如果父进程先于子进程消亡,那么子进程就变成孤儿进程。Linux内核会把它托孤给init进程(1号进程),init进程就成为子进程的父进程。


之前对c语言中的各个变量在内存中的位置大概有了了解。但我们可能对它并不理解。我们不知道这个地址空间究竟是什么。
这个地址空间是内存吗?

不是内存,那是什么呢?

系统中,只要是一个进程就要被操作系统管理,只要被操作系统管理,那么创建子进程时,就要拷贝父进程的内核数据结构,比如子进程需要创建PCB,否则无法对子进程管理。

父子进程谁先跑不一定,由系统调度器决定 

见现象

#include<stdio.h>
    2 #include<unistd.h>
    3 #include<sys/types.h>
    4 #include<stdlib.h>
    5 int global_value=0;
    6 int main()
    7 {
    8 pid_t id=fork();
    9 if(id<0)
   10 {
   11   printf("fork  error\n");                                                                                                                                                   
    12     return ;
   13 }
   14 else  if(id==0)
   15 {
   16   int  n=0;
   17   while(1)
   18   {
   19    printf("子进程, pid: 
%d,ppid:%d|global_value:%d,&global_value:%p\n",getpid(),getppid(),global_value,&global_value);
   20   sleep(1);
   21   n++;
   22   if(n==10)
   23   {
   24     global_value=300;
   25     printf("子进程全局变量已经更新了\n");
   26   }
   27   }
   28 }
   29 else  if(id>0)
   30 {
   31   while(1)
   32   {
W> 33     printf("父进 
程,pid:%d,ppid:%d|global_value:%d&global_value:\n",getpid(),getppid(),global_value,&global_value);
   34   sleep(2);
   35   }
   36 }
   37 sleep(1);
   38 return 0;
   39 }
 

 父子进程global_value值不同,地址相同。

多进程读取同一个地址的时候,怎么可能出现不同的结果?(继续往下看,答案在后面)

 地址没变,打出来的值不同,说明地址一定不是物理地址,物理地址相同打出来的值一定相同,所以之前语言阶段学习的地址(指针)不是物理地址。而是虚拟地址(线性地址)[逻辑地址]

感性理解虚拟地址空间
进程认为自己独占系统资源,实际并不是,(设计时的理念)

漂亮国有个大富翁有10亿美金,有三个私生子,(私生子彼此不知道对方存在),大儿子是工厂老板,二儿子是金融机构的CEO,三儿子在MIT读书,大富翁告诉大儿子,要好好工作,等到自己老了不行后将10亿美金都给他,大富翁又告诉二儿子,要经营好告诉,等自己老了不行时把10亿美金给他,大富翁又告诉小儿子,要好好读书,等自己老了不行时把10亿美金都给他。当大富翁老了,三个儿子都想要这笔钱,但他们只能找各种理由每次要一点,尽力去索要那10亿美金。

这里,大富翁就是操作系统,三个儿子就相当于三个进程,三个儿子每次要的钱相当于是这个进程申请的内存或对象空间。 大富翁画的三个大饼(要好好工作,等到自己老了不行后将10亿美金都给他)相当于进程地址空间。

计算机的很多理念不是凭空产生的,而是来源于生活。

系统如何画饼
画饼的本质是在大脑中绘制蓝图,相当于一个结构体对象,

所以地址空间本质是内核的一种数据结构--mm_struct

struct mm_struct中应该有哪些成员呢?

1.地址空间描述的基本空间大小是字节

2.32位下,2的32次方个地址

3.2的32次方个字节约为4GB

4.每个字节都有一个地址

如何理解区域划分
小时候,我们上学时,为了和同桌有相同的桌子面积,我们用尺子在桌子上画出一道线,这个过程相当于一个区域划分。

如何 理解区域调整
 此时男生因为较胖,想为自己多争取点空间,女生答应了请求,于是在原先桌子中间的线两旁,设置了10cm的缓冲区。

2的32次方个地址所占的4GB空间相当于这个区域,我们用unsigned int 类型表示地址。

 mm->code_start相当于一个区域的起始地址

mm->code_end相当于一个区域的结束地址

这些地址都是虚拟地址。所谓的区域调整,就是改变 start和end的值。

我们定义局部变量,malloc和new相当于扩大栈区和堆区;

函数调用完毕,及free相当于缩小栈区或堆区。

各个进程在4GB的空间,被分配不同的区域,相当于大富翁为儿子画的大饼,即共分10亿美金。

证明此结构
 

源码

如何让进程找到内存中的代码和收据,用页表。页表用来把虚拟地址和物理地址进行映射。此过程由操作系统自动操作。虚拟地址是连续的,也叫线性地址。

内存和磁盘I/O过程,基本单位是4kb。

每个进程认为自己独占2的32次方地址。其实是虚拟地址,而且这虚拟地址,进程也不会全用到。

为什么要用页表映射地址呢?

可以看做是小时候家长为自己管理压岁钱,怕乱花,家长相当于页表,起到拦截作用。

为什么存在地址空间?

1.如果让进程直接访问物理内存,万一进程越界非法操作呢?非常不安全 。

地址空间让我们写代码出现错误时,比如出现野指针时,并不影响内存及物理地址。 

2.可以更方便的进行进程和进程之间数据代码的解耦,保证了进程得独立性。(下面进行解答)

回顾一下开篇那个global_value问题 
 

父进程开辟子进程后,子进程相当于父进程的拷贝,子进程在父进程的虚拟地址处开辟,即虚拟地址不变,此时父子进程的物理地址也是相同的,数据也相同,当子进程更改数据时,因为进程具有独立性,一个进程对被共享的数据进行修改时,如果影响了其他进程,不能称子为独立性,所以当任何一个进程要对共享数据进行修改时,操作系统首先要重新在内存上为这个进程开辟一个物理内存空间,然后把原先数据拷贝到新的空间里面,然后将子进程页表物理地址更改为指向新的物理地址,然后把要更改的变量进行更改。此过程和虚拟地址无关。上层用的虚拟地址的同一区域,底层通过页表被映射到了物理地址的不同区域。此时我们看到虚拟地址一样,但内容却不一样。

 我们把任何一方尝试更改数据时,操作系统先进行数据拷贝,再更改页表的映射,然后让进程修改的过程叫写时拷贝。

操作系统为了保证进程的独立性,做了很多工作,通过地址空间,页表,让不同进程映射到不同物理内存中。

进程=内核数据结构+对应的代码和数据,通过写时拷贝让不同进程代码和数据独立,不同进程这俩个都是独立的,保证了进程的独立性。

问题:子进程刚被创建时的物理地址,页表,虚拟地址和数据,和父进程都是一样的,那怎么保证父子进程独立性的?

答:父子进程都有它自己独立的进程,虚拟的空间的,虽然说最开始的时候子进程和父进程一样,页表的映射关系也是一模一样的,但是它是两个独立的页表,各有各的页表,当发生数据修改的时候,会有一个写时拷贝的存在。父子进程发生数据修改时是独立的。进程的独立性主要体现在数据修改时是独立的,而不是体现在代码和数据一样。

 3.让进程以统一的视角看待进程对应的代码和数据等各个区域,方便编译器也以统一的视角编译代码。

问题:一个可执行程序,在磁盘中还没有加载到内存时有没有地址?

1.汇编指令有地址。说明程序在汇编阶段(汇编,编译,链接,可执行)代码就有地址,链接就是把库函数中的地址填入到可执行程序中,让程序运行时能找到库函数,此地址是逻辑地址。

2.虚拟地址不仅操作系统会遵守,编译器也会遵守。编译器编译代码时,就是按照虚拟地址空间的方式对代码和数据进行编址的。程序的代码区和数据区是以32位地址编址的。

main函数调用fun函数,是在代码的内部进行跳转的。 

 函数加载到内存后,函数内部的东西不变。

这些地址是编译阶段就有的, 栈空间和堆空间编译生成可执行程序时,这些地址没有,因为它们是在内存中动态申请的,

3.上面说的地址是程序内部的地址。是虚拟地址(逻辑地址)。代码要占空间,要在内存中保存,当程序被加载到物理内存中后,该程序对应的指令和数据,都天然具有了物理地址。

当程序加载到内存后,这些函数和变量可以相互通过虚拟地址(逻辑地址)找到。 这些变量和函数都有了物理地址。

                我们现在有了俩套地址

标识物理内存中代码和数据的地址
在程序内部互相跳转用的地址--虚拟地址。


操作系统通过输入到内存中的程序的虚拟地址和大小,给定程序地址空间的区域 。

CPU中有个pc指针,叫程序计数器,它也是读取和输出虚拟地址的。

 整个CPU访问过程中,CPU没有见到物理地址。

所以平时debug程序,运行起来时,CPU内部寄存器用的就是虚拟地址。调试时查看的是虚拟地址。

编译器写程序时32位和64位程序指的就是程序编译时虚拟地址(逻辑地址)按32还是64位进行编。

 两种程序中逻辑地址编码方式,上面较新,是线性编辑的,下面较旧,是靠偏移量编辑的。
————————————————
版权声明:本文为CSDN博主「南种北李」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:

线程退出资源回收方式

linux基础——进程的退出及资源回收-阿里云开发者社区

文章目录

进程的退出

returen 和 exit

代码示例

注册进程结束调用函数

代码示例(on_exit):

atexit

代码示例(atexit)

进程资源的回收

代码示例

wait回收进程资源

代码示例

waitpid

代码示例

给指定进程发送信号(kill)

僵尸进程

代码示例

在进程的虚拟地址空间加载新的映像

代码示例

使用system启动新的可执行程序

代码示例


进程的退出

returen 和 exit

return只是函数的返回,而exit却是进程的结束。

void exit(int status);

#include <stdlib.h>
void exit(int status);
功能:终止进程
参数:
status:退出状态码。status&0377的值给父进程。
返回值:
永远不返回。

代码示例

  • test.c
#include <stdio.h>
#include <stdlib.h>
int main(void){getchar();exit(-1);
}

  • 执行结果

注册进程结束调用函数

在进程结束前,可以注册一些函数给进程,在进程结束时会自动调用这些被注册的函数。

on_exit(3)

#include <stdlib.h>
int on_exit(void (*function)(int , void *), void *arg);
功能:注册一个函数给进程,在进程终止的时候调用该函数
参数:
function:指定退出函数的名字
void (*function)(int , void *)
arg:指定退出函数的第二个参数
返回值:
0    成功
非0   错误

代码示例(on_exit):

  • on_exit.c
#include <stdio.h>
#include <stdlib.h>
void doit(int n,void *arg){printf("n=%d\targ:%s\n",\n,(char *)arg);return;
}
int main(void){//向进程注册退出函数on_exit(doit,"beijing");getchar();exit(3);
}

  • 执行结果

atexit

atexit(3)

#include <stdlib.h>
int atexit(void (*function)(void));
功能:注册一个函数给进程,在进程终止的时候调用该函数
参数:
function:指定了要注册的函数的名字
返回值:
0    成功
非0   错误

代码示例(atexit)

  • atexit.c
#include <stdio.h>
#include <stdlib.h>
//注册给进程的退出函数
void doit(void){printf("hahha....\n");return;
}
int main(void){//向进程注册一个退出处理函数atexit(doit);getchar();return 0;
}

  • 执行结果

进程资源的回收

在进程退出后,父进程会回收子进程的资源。

使用wait(2)、waitpid(2)系统调用回收子进程的资源。

如果父进程早于子进程结束,那么父进程的子进程的父亲就改变成为init进程,这种进程被成为孤儿进程。

代码示例

  • lonely.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void){pid_t pid;//创建子进程pid=fork();if(pid==-1){perror("fork");return 1;}if(pid==0){//子进程的代码sleep(5);printf("child...\n");//getchar();exit(0);}else{//父进程的代码printf("parent...\n");exit(0);}return 0;
}

  • 执行结果

wait回收进程资源

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
功能:等待进程改变状态。
参数:
status:退出状态码的地址。子进程的退出状态存放在这块地址空间里。可以使用一些宏检测退出原因。
WIFEXITED(status)  如果正常死亡,返回真
WEXITSTATUS(status)  返回子进程的退出状态和0377的与,那个值。
WIFSIGNALED(status) 如果子进程被信号终止,返回真
WTERMSIG(status)  检测被几号信号终止。只有上个宏为真的时候,才使用。
返回值:
-1   错误
返回终止的子进程的pid

代码示例

  • wait.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void){pid_t pid;int s;//创建子进程pid=fork();if(pid==-1){perror("fork");return 1;}if(pid==0){printf("child pid=%d\n",\getpid());//sleep(5);getchar();exit(-1);}else{//等待子进程的结束wait(&s);if(WIFEXITED(s)){//子进程正常终止printf("status:%d\n",       WEXITSTATUS(s));}//检测子进程是否被信号终止if(WIFSIGNALED(s)){//输出终止子进程的信号编号printf("signum :%d\n",\WTERMSIG(s));}printf("parent...\n");}return 0;
}

  • 执行结果

waitpid

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

功能:等待进程改变状态。
参数:
pid:
< -1: pid取绝对值,如果子进程的组id等于这个绝对值,那么这个子进程就被等待。
-1:等待任意子进程
0:等待和当前进程有同一个组id的子进程
> 0   等待子进程的pid是pid参数的子进程。
status:同wait(2)参数的使用
options:
WNOHANG:非阻塞回收。
0    阻塞回收
返回值:
-1   错误  
0   没有子进程退出
回收的子进程的pid

代码示例

  • waitpid.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void){pid_t pid;int s;//创建子进程pid=fork();if(pid==-1){perror("fork");return 1;}if(pid==0){printf("child pid=%d\n",\getpid());//sleep(5);getchar();exit(-1);}else{//非阻塞等待子进程的结束waitpid(-1,&s,WNOHANG);if(WIFEXITED(s)){//子进程正常终止printf("status:%d\n",       WEXITSTATUS(s));}//检测子进程是否被信号终止if(WIFSIGNALED(s)){//输出终止子进程的信号编号printf("signum :%d\n",\WTERMSIG(s));}printf("parent...\n");}return 0;
}

  • 执行结果

给指定进程发送信号(kill)

kill -[信号编号] [进程的pid]

僵尸进程

子进程已经终止,但是父进程还没有回收子进程的资源,这时候的子进程处于僵尸状态,成为僵尸进程。

代码示例

  • zombile.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
int main(void){pid_t pid;pid=fork();if(pid==-1){perror("fork");return 1;}if(pid==0){exit(0);}else{sleep(20);wait(NULL);}return 0;
}

在进程的虚拟地址空间加载新的映像

在子进程的虚拟地址空间加载新的影像,需要使用系统提供的一个家族的函数。

execl(3)

#include <unistd.h>
extern char **environ;
int execl(const char *path,  const  char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const  char *arg,\..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const   char  *file,  char *const argv[],char *const envp[]);

execve(2)

#include <unistd.h>
int  execve(const  char  *filename, char *const argv[],\char *const envp[]);
相同的exec
l list   
v vector
p PATH    
e 环境变量
返回值:
成功调用永远不返回
-1  错误   errno被设置

代码示例

  • exec.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
char *const ps_argv[]={"ps","-o","pid,ppid,pgrp,comm",NULL};
int main(void){pid_t pid;//创建子进程pid=fork();if(pid ==-1){perror("fork");return 1;}if(pid==0){//加载新映像//execl("/bin/ps","ps","-o",\"pid,ppid,pgrp,comm",NULL);//execlp("ps","ps","-o",\"pid,ppid,pgrp,comm",NULL);execvp("ps",ps_argv);}else{wait(NULL);}return 0;
}

  • 执行结果:

使用system启动新的可执行程序

#include <stdlib.h>
int system(const char *command);
功能:执行一个shell命令
参数:
command:可执行命令
返回值:
-1  错误
返回command的退出状态码。

代码示例

  • system.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <unistd.h>
int main(void){pid_t pid;pid=fork();if(pid==-1){return 1;}if(pid==0){execl("./myt","myt",NULL);//system("myt");exit(0);}else{wait(NULL);}return 0;
}

  • 执行结果

进程创建接口fork+vfork+clone+Kthreadd+ - 进程管理(十一)

 fork,vfork,clone都是linux系统调用,这三个函数分别调用sys_fork,sys_vfork,sys_clone,最终都会调用到do_fork函数创建线程,同时置位TIF_NEED_RESCHED调度标志位触发后面调度。差别就在于参数的传递和一些准备工作的不同,上一章节已经详细学习了fork的流程,本章主要专注学习这三个接口函数的使用方法和差异点。

1 进程的四要素
linux进程所必须的四个要素:

程序代码,有一段程序供其执行: 代码不一定是进程专有,可以与其它进程共享
有自己专用系统堆栈空间:
有进程控制块(task_struct):
有独立的存储空间:
以上4条,缺一不可。如果缺少第四条,那么就称其为"线程"。如果完全没有用户空间,称其位”内核线程“;如果共享用户空间,则称其为”用户线程"。

2 fork
系统调用fork,允许父进程创建一个新的进程(子进程)。新的子进程是父进程的翻版:完全继承父进程的栈、数据段、堆和执行文本的拷贝。其接口如下:

NAME
       fork - create a child process

SYNOPSIS
       #include <sys/types.h>
       #include <unistd.h>

       pid_t fork(void);
1
2
3
4
5
6
7
8
完成对其调用后将存在两个进程,且每个进程都会从fork的返回处继续执行。这两个进程将执行相同的程序文本段,但却各自拥有不同的栈段、数据段以及堆段拷贝。子进程的栈、数据以及栈段开始时是对父进程内存相应各部分的完全复制。执行fork之后,每个进程均可修改各自的栈数据以及堆中的变量而不影响另一进程。


为调用进程创建一个一模一样的新进程,但父子进程需要改变时候,执行一个copy,但是任何修改都造成分裂,如:chroot, open, 写memory,mmap,sigaction….

fork的示例

考虑以下代码的输出,假设test.txt中的内容”abcdefghijklmnopqrst…”

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include<fcntl.h>

int main(void)
{
        char str[10];
        int count = 1;
        int fd = open("test.txt", O_RDWR);
        if(fork() == 0)
        {
                int cnt = read(fd, str, 10);
                printf("Child process : %s\n", (char *)str);
                printf("This is son, his count is: %d (%p). and his pid is: %d\n", ++count, &count, getpid());
        }
        else
        {
                int cnt = read(fd, str, 10);
                printf("Child process : %s\n", (char *)str);
                printf("This is father, his count is: %d (%p), his pid is: %d\n", count, &count, getpid());
        }

    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
输出为:


从结果来看,子进程和父进程的PID不同,内存资源count是值的复制,子进程改变了count的值,而父进程中的count的值没有改变,这个过程请参考之前章节的写时复制技术。

两个进程共享了同一个指向文件的结构体,所以当子进程输出“abcdefghij”后,父进程就接着输出"klmnopqrst"


3 vfork
vfork也是创建子进程,但是子进程共享父进程的空间。在vfork创建子进程之后,父进程阻塞,直到子进程执行exec()或者exit()。vfork设计的最初是因为fork没有实现COW机制,很多情况下fork之后会紧跟着exec,而exec的执行相当于前面fork复制的空间全部变得无用,所以设计了vfork。而现在fork使用了COW,唯一的代价仅仅是复制父进程页表的代价,所以vfork的功能就变得越来越不重要。

NAME
       vfork - create a child process and block parent

SYNOPSIS
       #include <sys/types.h>
       #include <unistd.h>

       pid_t vfork(void);
1
2
3
4
5
6
7
8
vfork因为如下两个特性而更具效率,也是区别与fork所在:

无需为子进程复制虚拟内存页或页表,相反,子进程共享父进程的内存,直至其成功执行exec或调用exit退出
在子进程调用exit或exec之前,将暂停执行父进程,所以在使用vfork时,一般立即在vfork之后调用exec,如果exec调用失败,子进程应调用exit退出。

vfork示例

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>


int main(void)
{
    int count =1;
    int child;

    printf("Before create son, the father's count is %d\n",count);
    if(!(child = vfork()))
    {
        int i = 0;
        for( i = 0; i< 3; i++)
        {
            count++;
            printf("This is son This i is: %d count: %d\n", i, count);
            if(i == 2)
            {
                printf("This is son This pid is: %d count: %d\n", getpid(), count);    
                exit(1);
            }
        }
    }
    else
    {
        printf("This is father This pid is: %d count: %d\n", getpid(), count); 
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
输出:


用vfork创建的子进程与父进程共享地址空间,也就是说子进程完全运行在父进程的地址空间上,如果这时子进程修改了某个变量,这将影响到父进程
子进程在vfork()返回后直接运行在父进程的栈空间,并使用父进程的内存和数据。这意味着子进程可能破坏父进程的数据结构或栈,造成失败。为了避免这些问题,需要确保一旦调用vfork(),子进程就不从当前的栈框架中返回,并且如果子进程改变了父进程的数据结构就不能调用exit函数。子进程还必须避免改变全局数据结构或全局变量中的任何信息,因为这些改变都有可能使父进程不能继续。
值得注意的是用vfork创建的子进程必须显示调用exit()来结束,否则子进程将不能结束,父进程就讲一直阻塞,出现异常
大家可以实际将上述例子中exit(1)这个注释掉后,会出现什么情况。对于Vfork和fork是类似的,除了下面两点:
1、阻塞父进程
2、不复制父进程的页表

之所以vfork要阻塞父进程是因为vfork后父子进程使用的是完全相同的mm_struct,也就是由完全相同的虚拟地址空间,包括栈也相同,所以两个进程就不能同时运行,否则栈就会乱掉。所以vfork后,父进程是阻塞的,直到调用了exec系列或者exit后,这个时候,子进程的mm需要释放,不再与父进程公用,这个时候就可以解除父进程的阻塞状态。

4 clone
clone是Linux为创建线程设计的,所以可以说clone是fork的升级版本,不仅可以创建进程或线程,还可以指定创建新的命名空间,有选择的继承父进程的内存、甚至可以将创建出来的进程编程父进程的兄弟进程等。

clone函数功能强大,待有很多参数,提供了一个非诚灵活自由的常见进程的方法,因此它创建进程要比前面两种方法更为复杂。clone可以有选择继承父进程的资源,你可以选择像vfork一样和父进程共享一个虚拟存储空间,也可以不和父进程共享,甚至可以选择创造出来的进程和父进程不再是父子关系,而是兄弟关系。

NAME
       clone, __clone2 - create a child process

SYNOPSIS
       /* Prototype for the glibc wrapper function */

       #define _GNU_SOURCE
       #include <sched.h>

       int clone(int (*fn)(void *), void *child_stack,
                 int flags, void *arg, ...
                 /* pid_t *ptid, void *newtls, pid_t *ctid */ );
1
2
3
4
5
6
7
8
9
10
11
12
参数    含义
fn为函数指针    此指针指向一个函数体,即想要创建进程的静态程序
child_stack    为给子进程分配系统堆栈的指针
arg    传给子进程的参数一般为(0)
flags    要复制资源的标志,描述你需要从父进程继承那些资源(是资源复制还是共享,在这里设置参数)
下面是flaga可以取得值

标志    含义
CLONE_PARENT    建的子进程的父进程是调用者的父进程,新进程与创建它的进程成了“兄弟”而不是“父子”
CLONE_FS    子进程与父进程共享相同的文件系统,包括root、当前目录、umask
CLONE_FILES    子进程与父进程共享相同的文件描述符(file descriptor)表
CLONE_NEWNS    在新的namespace启动子进程,namespace描述了进程的文件hierarchy
CLONE_SIGHAND    子进程与父进程共享相同的信号处理(signal handler)表
CLONE_PTRACE    若父进程被trace,子进程也被trace
CLONE_VFORK    父进程被挂起,直至子进程释放虚拟内存资源
CLONE_VM    子进程与父进程运行于相同的内存空间
CLONE_PID    子进程在创建时PID与父进程一致
CLONE_THREAD    Linux 2.4中增加以支持POSIX线程标准,子进程与父进程共享相同的线程群


#define _GNU_SOURCE
#include <sys/wait.h>
#include <sys/utsname.h>
#include <sched.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
#define FIBER_STACK 8192

int a;
void *stack;

int do_something()
{
    a = 10;
    printf("This is son, the pid is: %d, the a is: %d\n",getpid(), a);
    free(stack);
    exit(1);
}

int main(void)
{
    void *stack;
    a = 1;
    stack = malloc(FIBER_STACK);
    if(!stack)
    {
        printf("The stack failed\n");
        exit(0);
    }

    printf("Create son thread \n");
    clone(&do_something, (char *)stack + FIBER_STACK, CLONE_VM | CLONE_VFORK, 0);
    printf("This is father, the pid is: %d, the a is: %d\n",getpid(), a);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
输出结果:

inux创建线程的API,本质上去调 clone。要求把P2的所有资源的指针,都指向P1。线程,也被称为 Light weight process。而Linux在clone线程时也十分灵活,可以选择共享/不共享部分资源。

POSIX标准要求,进程里面如果有多个线程,在用户空间 getpid() 看到的都是同一个id,这个id其实是TGID。一个进程里面创建了多个线程,在/proc 下 的是 tgid,/proc/tgid/task/{pidx,y,z}pthread_self() 看到的是用户空间pthread线程库里获得的id 。

5. 总结
下面是三个接口的优缺点对比

类型    优点    缺点
fork    1. 接口非常简洁
2. 将进程“创建”和执行(exec)解耦,提高了灵活性
3. 刻画了进程间的内在关系(进程树、进程组)    1. 完全拷贝,过于粗暴(不如clone)
2. 性能差,可扩展性差(不如vfork)
3. 不可组合(如fork()+pthread())
vfork    1. 类似fork,但让父子进程共享同一地址空间
2. 连映射关系都不需要拷贝,性能更好    1. 只能用在fork+exec的场景中
2. 共享地址空间存在安全问题
clone    1. fork的进阶版本,可以选择地不拷贝内存
2. 高度可控,可按照需求调整    接口比fork复杂,选择性拷贝容易出错

 在Linux系统中,前面我们接触了用户进程或用户进程,但是在实际的也是有内核线程的存在,例如我们在内存管理章节中熟悉的内存回收进程kswapd,软中断等。本章主主要包括内核线程的创建和结束的完整过程。

1. Linux线程管理
Linux内核在启动的时候,是没有线程的概念,当内核初始化完成后将启动一系列的线程,之后,CPU执行流就绑定在一个线程中运行,内核线程和用户线程的区别如下图所示:

每一个线程创建之初都是内核线程;创建之后如果与具体的进程上下文绑定,那线程就成了用户线程
如果绑定的内核线程,那么执行内核线程的服务代码,对于内核线性是没有地址空间的概念,准确的来说是没有用户地址空间的概念,使用的是所有进程共享的内核地址空间,但是调度的时候会借用前一个进程的地址空间


1.1 线程主要数据结构
内核线程也是用task_struct的数据结构表示,其跟用户空间的数据结构含义基本类似,我们重点关注以下内容

1.2 线程内核接口
Linux线程的实现相当复杂,但使用却比较简单。使用接口封装成了几个函数,其他模块只需直接使用这些函数,比如只需调用一句宏即可

struct task_struct *kthread_create_on_node(int (*threadfn)(void *data),
                       void *data,
                       int node,
                       const char namefmt[], ...);

#define kthread_create(threadfn, data, namefmt, arg...) \
    kthread_create_on_node(threadfn, data, NUMA_NO_NODE, namefmt, ##arg)


struct task_struct *kthread_create_on_cpu(int (*threadfn)(void *data),
                      void *data,
                      unsigned int cpu,
                      const char *namefmt);

#define kthread_run(threadfn, data, namefmt, ...)               \
({                                       \
    struct task_struct *__k                           \
        = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
    if (!IS_ERR(__k))                           \
        wake_up_process(__k);                       \
    __k;                                   \
})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1.3 内核线程和用户线程
CPU在执行和调度中并不会区分是用户线程还是内核线程,用户线程无非就是多一个用户空间的堆栈管理而已。本文讲解包括用户线程和内核线程在内的创建逻辑。

Linux用户线程(属于用户进程),都是在用户空间创建,虽然线程是内核调度的基本单位,但是用户线程的堆栈并不是由内核管理,它是由系统库创建和管理。所以,用户的线程的资源是由用户库来管理的,和内核线程堆没有必然的关系,唯一依赖的就是靠内核的线程对象来调度执行。详细的过程参考pthread的库实现,其最终会调用到底层的clone接口。

内核线程通过kthread_create_on_node()函数创建,下面我们来看看内核线程的实现过程

函数已经做了详细的注释,这里主要完成以下内容

首先将需要在内核线程中执行的函数等信息封装到kthread_create_info结构体中,然后加入到kthreadd的kthread_create_list链表中
接着去唤醒kthreadd去处理创建的内核线程请求,由它来执行具体的创建工作,之后通过一个completion对象等待创建结构
创建成功后,线程就可以调度执行了
2. 内核线程的创建
内核专门提供了Kthreadd线程用来处理内核线程,kthreadd线程在内核启动的时候就创建好了,一直不会退出,当没有创建任务时,主动放弃CPU时间,调用schedule()执行调度程序;当任务到来后,它会被再次唤醒,执行具体的创建线程任务。

kthreadd函数中设置了线程名字和亲和性属性之后,然后进入循环处理流程
首先将自己的状态置为TASK_INTERRUPTIBLE,然后判断kthread_create_list链表是否为空,这个链表存放其他内核路径的创建内核线程的请求结构struct kthread_create_info,对于创建内核线程时,会封装kthread_create_info结构然后加入到kthread_create_list
如果kthread_create_list链表为空,说明没有创建内核线程请求,直接调用schedule进行睡眠;当某个内核路径有kthread_create_info加入到kthread_create_list链表中并唤醒kthreadd后,kthreadd从__set_current_state(TASK_RUNNING)开始执行,设置状态为运行状态,然后进入一个循环,不断的从kthread_create_list.next取出kthread_create_info结构,并从链表中删除,调用create_kthread创建一个内核线程来执行剩余的工作
create_kthread很简单,就是创建内核线程,然后执行kthread函数,将取到的kthread_create_info结构传递给这个函数


create_thread()函数实际上调用kernel_thread(),而它又最终调用_do_fork()创建线程。创建的时候并不会把线程函数直接传递进去,而是先传入一个公共的代理函数,待代理函数起来,并进行一些初始化后,才开始执行线程函数。


通知completion对象,创建工作已经完成,线程已经运行起来了,接着主动调用schedule()放弃CPU时间,以便创建者可以快速的获得调度,知道创建结果。

_do_fork()不会重头分配每一个task_struct对象的数据,而是从父线程那里拷贝一个副本回来,跟据参数不同,拷贝的内容也各有差异,这里不去细分如何差别对待拷贝参数,可以看前面的章节 进程管理(八)–创建进程fork_奇小葩-CSDN博客

3 内核线程的结束
内核线程一旦启动起来后,会一直运行,除非该线程主动调用do_exit函数,或者其他的进程调用kthread_stop函数,结束线程的运行。如果线程函数正在处理一个非常重要的任务,它不会被中断的。当然如果线程函数永远不返回并且不检查信号,它将永远都不会停止。

在线程函数里,完成所需要的业务逻辑工作


值得一提的是kthread_should_stop函数,我们需要在开启的线程中嵌入该函数并检查此函数的返回值,否则kthread_stop是不起作用的


这个函数在kthread_stop()被调用后返回真,当返回为真时你的处理函数要返回,返回值会通过kthread_stop()返回。所以你的处理函数应该有判断kthread_should_stop然后退出的代码


4 总结
内核线程起始就是运行在内核地址空间的进程,它和普通的用户进程的区别在于内核线程没有独立的进程地址空间,即task_struct数据结构中mm指针为NULL,它只能运行在内核地址空间,和普通的进程一样参与系统的调度中。所有的内核线程都共享内核地址空间。

 

更多推荐

创建进程fork与终止

本文发布于:2024-02-06 09:29:37,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1748013.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:进程   fork

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!