谨慎使用多线程中的fork

在单核时代,大家所编写的程序都是单进程/单线程程序。随着计算机硬件技术的发展,进入了多核时代来临后,为了降低响应时间,重复充分利用多核cpu的资源。

多进程编程的手段逐渐被人们接受和掌握。然而因为创建一个进程代价比较大,多线程编程的手段也就逐渐被人们认可和喜爱了。

然而,对于我们编程者来说,多线程+多进程方式编程不是更好吗,然而事实并非如此

进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都是运行在某个进程的context中的。context是由程序正确运行所需的状态组成的,这个状态包括存放在存储器中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器(PC)、环境变量以及打开的文件描述符的集合。

进程主要提供给上层的应用程序两个抽象:

  • 一个独立的逻辑控制流,它提供一个假象,好像我们程序独占的使用处理器。
  • 一个私有的虚拟地址空间,它提供一个假象,好像我们的程序独占的使用存储器系统。

线程,就是运行在进程context中的逻辑流。线程由内核自动调度。每个线程都有它自己的线程context,包括一个唯一的整数线程ID、栈、栈指针、程序计数器(PC)、通用目的寄存器和条件码。每个线程和运行在同一进程内的其他线程一起共享进程context的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成。线程也同样共享打开文件的集合。

即进程是资源管理的最小单位,而线程是程序执行的最小单位。

在linux系统中,posix线程可以“看做”为一种轻量级的进程,pthread_create创建线程和fork创建进程都是在内核中调用__clone函数创建的,只不过创建线程或进程的时候选项不同,比如是否共享虚拟地址空间、文件描述符等。

我们知道通过fork创建的一个子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝,包括文本、数据和bss段、堆以及用户栈等。

子进程还获得与父进程任何打开文件描述符相同的拷贝,这就意味着子进程可以读写父进程中任何打开的文件,父进程和子进程之间最大的区别在于它们有着不同的PID。

但是有一点需要注意的是,在Linux中,fork的时候只复制当前线程到子进程,在fork(2)-Linux Man Page中有着这样一段关键的描述:

子进程是用一个线程创建的——这个线程调用了fork()。父进程的整个虚拟地址空间被复制到子进程中,包括互斥体、条件变量和其他pthreads对象的状态;使用pthread_atfork(3)可能有助于处理可能导致的问题。

也就是说除了调用fork的线程外,其他线程在子进程中“蒸发”了。

这就是多线程中fork所带来的一切问题的根源所在了。

互斥锁,就是多线程fork大部分问题的关键部分。

在大多数操作系统上,为了性能的因素,锁基本上都是实现在用户态的而非内核态(因为在用户态实现最方便,基本上就是通过原子操作或者之前文章中提到的memory barrier实现的),所以调用fork的时候,会复制父进程的所有锁到子进程中。

问题就出在这了。从操作系统的角度上看,对于每一个锁都有它的持有者,即对它进行lock操作的线程。假设在fork之前,一个线程对某个锁进行的lock操作,即持有了该锁,然后另外一个线程调用了fork创建子进程。可是在子进程中持有那个锁的线程却\”消失\”了,从子进程的角度来看,这个锁被“永久”的上锁了,因为它的持有者“蒸发”了。

如果一个多线程程序调用了fork,那么新创建的子进程只拥有一个执行线程(调用fork的那个线程的完整复制),并且子进程自动继承父进程的互斥锁(条件变量)的状态,即父进程中被加锁的互斥量在子进程中也是被锁住的。于是子进程不知道继承而来的互斥量的具体状态,若互斥量在fork前已经被加锁,子进程若再对互斥量加锁则一定死锁。当然若fork后的子进程立即调用exec()执行其它程序隔断了父子关系则不会出现上述情形。

那么如果子进程中的任何一个线程对这个已经被持有的锁进行lock操作话,就会发生死锁。

当然了有人会说可以在fork之前,让准备调用fork的线程获取所有的锁,然后再在fork出的子进程的中释放每一个锁。先不说现实中的业务逻辑以及其他因素允不允许这样做,这种做法会带来一个问题,那就是隐含了一种上锁的先后顺序,如果次序和平时不同,就会发生死锁。

如果你说自己一定可以按正确的顺序上锁而不出错的话,还有一个隐含的问题是你所不能控制的,那就是库函数。

因为你不能确定你所用到的所有库函数都不会使用共享数据,即他们都是完全线程安全的。有相当一部分线程安全的库函数都是在内部通过持有互斥锁的方式来实现的,比如几乎所有程序都会用到的C/C++标准库函数malloc、printf等等。

比如一个多线程程序在fork之前难免会分配动态内存,这就必然会用到malloc函数;而在fork之后的子进程中也难免要分配动态内存,这也同样要用到malloc,可这却是不安全的,因为有可能malloc内部的锁已经在fork之前被某一个线程所持有了,而那个线程却在子进程中消失了。

按照上文的分析,似乎多线程中在fork出的子进程中立刻调用exec函数是唯一明智的选择了,其实即使这样做还是有一点不足。因为子进程会继承父进程中所有已打开的文件描述符,所以在执行exec之前子进程仍然可以读写父进程中的文件,但如果你不希望子进程能读写父进程里的某个已打开的文件该怎么办?

或许fcntl设置文件属性是一种办法:

int fd = open(\”file\”, O_RDWR | O_CREAT);

if (fd < 0)

{

perror(\”open\”);

}

fcntl(fd, F_SETFD, FD_CLOEXEC);

但是如果在open打开file文件之后,调用fcntl设置CLOEXEC属性之前有其他线程fork出了子进程了的话,这个子进程仍然是可以读写file文件。如果用锁的话,就又回到了上文所讨论的情况了。

从Linux 2.6.23版本的内核开始,我们可以在open中设置O_CLOEXEC标志了,相当于“打开文件再设置CLOEXEC”成为了一个原子操作。这样在fork出的子进程执行exec之前就不能读写父进程中已打开的文件了。

如果你不幸真的碰到了一个要解决多线程中fork的问题的时候,可以尝试使用pthread_atfork:

  • prepare处理函数由父进程在fork创建子进程前调用,这个函数的任务是获取父进程定义的所有锁。
  • parent处理函数是在fork创建了子进程以后,但在fork返回之前在父进程环境中调用的。它的任务是对prepare获取的所有锁解锁。
  • child处理函数在fork返回之前在子进程环境中调用,与parent处理函数一样,它也必须解锁所有prepare中所获取的锁。

因为子进程继承的是父进程的锁的拷贝,所有上述并不是解锁了两次,而是各自独自解锁。可以多次调用pthread_atfork函数从而设置多套fork处理程序,但是使用多个处理程序的时候。处理程序的调用顺序并不相同。parent和child是以它们注册时的顺序调用的,而prepare的调用顺序与注册顺序相反。这样可以允许多个模块注册它们自己的处理程序并且保持锁的层次(类似于多个RAII对象的构造析构层次)。

需要注意的是pthread_atfork只能清理锁,但不能清理条件变量。在有些系统的实现中条件变量不需要清理。但是在有的系统中,条件变量的实现中包含了锁,这种情况就需要清理。但是目前并没有清理条件变量的接口和方法。

  • 在多线程程序中最好只用fork来执行exec函数,不要对fork出的子进程进行其他任何操作。
  • 如果确定要在多线程中通过fork出的子进程执行exec函数,那么在fork之前打开文件描述符时需要加上CLOEXEC标志。

什么是ForkJoin?看这一篇就能掌握

本文分享自华为云社区《【高并发】什么是ForkJoin?看这一篇就够了!》,作者: 冰 河。

在JDK中,提供了这样一种功能:它能够将复杂的逻辑拆分成一个个简单的逻辑来并行执行,待每个并行执行的逻辑执行完成后,再将各个结果进行汇总,得出最终的结果数据。有点像Hadoop中的MapReduce。

ForkJoin是由JDK1.7之后提供的多线程并发处理框架。ForkJoin框架的基本思想是分而治之。什么是分而治之?分而治之就是将一个复杂的计算,按照设定的阈值分解成多个计算,然后将各个计算结果进行汇总。相应的,ForkJoin将复杂的计算当做一个任务,而分解的多个计算则是当做一个个子任务来并行执行。

对于Java语言来说,生来就支持多线程并发编程,在并发编程领域也是在不断发展的。Java在其发展过程中对并发编程的支持越来越完善也正好印证了这一点。

  • Java 1 支持thread,synchronized。
  • Java 5 引入了 thread pools, blocking queues, concurrent collections,locks, condition queues。
  • Java 7 加入了fork-join库。
  • Java 8 加入了 parallel streams。

并发和并行在本质上还是有所区别的。

并发指的是在同一时刻,只有一个线程能够获取到CPU执行任务,而多个线程被快速的轮换执行,这就使得在宏观上具有多个线程同时执行的效果,并发不是真正的同时执行,并发可以使用下图表示。

并行指的是无论何时,多个线程都是在多个CPU核心上同时执行的,是真正的同时执行。

把一个规模大的问题划分为规模较小的子问题,然后分而治之,最后合并子问题的解得到原问题的解。

①分割原问题;

②求解子问题;

③合并子问题的解为原问题的解。

我们可以使用如下伪代码来表示这个步骤。

在分治法中,子问题一般是相互独立的,因此,经常通过递归调用算法来求解子问题。

  • 二分搜索
  • 大整数乘法
  • Strassen矩阵乘法
  • 棋盘覆盖
  • 合并排序
  • 快速排序
  • 线性时间选择
  • 汉诺塔

Java 1.7 引入了一种新的并发框架—— Fork/Join Framework,主要用于实现“分而治之”的算法,特别是分治之后递归调用的函数。

ForkJoin框架的本质是一个用于并行执行任务的框架, 能够把一个大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务的计算结果。在Java中,ForkJoin框架与ThreadPool共存,并不是要替换ThreadPool

其实,在Java 8中引入的并行流计算,内部就是采用的ForkJoinPool来实现的。例如,下面使用并行流实现打印数组元组的程序。

这段代码的背后就使用到了ForkJoinPool。

说到这里,可能有读者会问:可以使用线程池的ThreadPoolExecutor来实现啊?为什么要使用ForkJoinPool啊?ForkJoinPool是个什么鬼啊?! 接下来,我们就来回答这个问题。

ForkJoin框架是从jdk1.7中引入的新特性,它同ThreadPoolExecutor一样,也实现了Executor和ExecutorService接口。它使用了一个无限队列来保存需要执行的任务,而线程的数量则是通过构造函数传入,如果没有向构造函数中传入指定的线程数量,那么当前计算机可用的CPU数量会被设置为线程数量作为默认值。

ForkJoinPool主要使用**分治法(Divide-and-Conquer Algorithm)**来解决问题。典型的应用比如快速排序算法。这里的要点在于,ForkJoinPool能够使用相对较少的线程来处理大量的任务。比如要对1000万个数据进行排序,那么会将这个任务分割成两个500万的排序任务和一个针对这两组500万数据的合并任务。以此类推,对于500万的数据也会做出同样的分割处理,到最后会设置一个阈值来规定当数据规模到多少时,停止这样的分割处理。比如,当元素的数量小于10时,会停止分割,转而使用插入排序对它们进行排序。那么到最后,所有的任务加起来会有大概200万+个。问题的关键在于,对于一个任务而言,只有当它所有的子任务完成之后,它才能够被执行。

所以当使用ThreadPoolExecutor时,使用分治法会存在问题,因为ThreadPoolExecutor中的线程无法向任务队列中再添加一个任务并在等待该任务完成之后再继续执行。而使用ForkJoinPool就能够解决这个问题,它就能够让其中的线程创建新的任务,并挂起当前的任务,此时线程就能够从队列中选择子任务执行。

那么使用ThreadPoolExecutor或者ForkJoinPool,性能上会有什么差异呢?

首先,使用ForkJoinPool能够使用数量有限的线程来完成非常多的具有父子关系的任务,比如使用4个线程来完成超过200万个任务。但是,使用ThreadPoolExecutor时,是不可能完成的,因为ThreadPoolExecutor中的Thread无法选择优先执行子任务,需要完成200万个具有父子关系的任务时,也需要200万个线程,很显然这是不可行的,也是很不合理的!!

假如我们需要做一个比较大的任务,我们可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

工作窃取算法的优点:充分利用线程进行并行计算,并减少了线程间的竞争。

工作窃取算法的缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且该算法会消耗更多的系统资源,比如创建多个线程和多个双端队列。

Fork/Join框架局限性:

对于Fork/Join框架而言,当一个任务正在等待它使用Join操作创建的子任务结束时,执行这个任务的工作线程查找其他未被执行的任务,并开始执行这些未被执行的任务,通过这种方式,线程充分利用它们的运行时间来提高应用程序的性能。为了实现这个目标,Fork/Join框架执行的任务有一些局限性。

(1)任务只能使用Fork和Join操作来进行同步机制,如果使用了其他同步机制,则在同步操作时,工作线程就不能执行其他任务了。比如,在Fork/Join框架中,使任务进行了睡眠,那么,在睡眠期间内,正在执行这个任务的工作线程将不会执行其他任务了。(2)在Fork/Join框架中,所拆分的任务不应该去执行IO操作,比如:读写数据文件。(3)任务不能抛出检查异常,必须通过必要的代码来出来这些异常。

ForkJoin框架中一些重要的类如下所示。

ForkJoinPool 框架中涉及的主要类如下所示。

1.ForkJoinPool类

实现了ForkJoin框架中的线程池,由类图可以看出,ForkJoinPool类实现了线程池的Executor接口。

我们也可以从下图中看出ForkJoinPool的类图关系。

其中,可以使用Executors.newWorkStealPool()方法创建ForkJoinPool。

ForkJoinPool中提供了如下提交任务的方法。

2.ForkJoinWorkerThread类

实现ForkJoin框架中的线程。

3.ForkJoinTask<V>类

ForkJoinTask封装了数据及其相应的计算,并且支持细粒度的数据并行。ForkJoinTask比线程要轻量,ForkJoinPool中少量工作线程能够运行大量的ForkJoinTask。

ForkJoinTask类中主要包括两个方法fork()和join(),分别实现任务的分拆与合并。

fork()方法类似于Thread.start(),但是它并不立即执行任务,而是将任务放入工作队列中。跟Thread.join()方法不同,ForkJoinTask的join()方法并不简单的阻塞线程,而是利用工作线程运行其他任务,当一个工作线程中调用join(),它将处理其他任务,直到注意到目标子任务已经完成。

我们可以使用下图来表示这个过程。

ForkJoinTask有3个子类:

  • RecursiveAction:无返回值的任务。
  • RecursiveTask:有返回值的任务。
  • CountedCompleter:完成任务后将触发其他任务。

4.RecursiveTask<V> 类

有返回结果的ForkJoinTask实现Callable。

5.RecursiveAction类

无返回结果的ForkJoinTask实现Runnable。

6.CountedCompleter<T> 类

在任务完成执行后会触发执行一个自定义的钩子函数。

最后,附上并发编程需要掌握的核心技能知识图,祝大家在学习并发编程时,少走弯路。

关注我,第一时间了解华为云新鲜技术~

「技术干货」Linux实现了三个fork,vfork,cloneUnix

Unix标准的复制进程的系统调用时fork(即分叉),但是Linux,BSD等操作系统并不止实现这一个,确切的说linux实现了三个,fork,vfork,clone(确切说vfork创造出来的是轻量级进程,也叫线程,是共享资源的进程)

嵌入式进阶教程分门别类整理好了,看的时候十分方便,由于内容较多,这里就截取一部分图吧。

需要的朋友私信【内核】即可领取

内核学习网站:

系统调用描述

  1. fork fork:创造的子进程是父进程的完整副本,复制了父亲进程的资源,包括内存的内容task_struct内容
  2. vfork vfork:创建的子进程与父进程共享数据段,而且由vfork()创建的子进程将先于父进程运行
  3. clone Linux:上创建线程一般使用的是pthread库 实际上linux也给我们提供了创建线程的系统调用,就是clone

从运行结果里面可以看出父子两个进程的pid不同,堆栈和数据资源都是完全的复制;子进程改变了count的值,而父进程中的count没有被改变。子进程与父进程count的地址(虚拟地址)是相同的(注意他们在内核中被映射的物理地址不同)

有人认为这样大批量的复制会导致执行效率过低。其实在复制过程中,linux采用了写时复制的策略。

子进程复制了父进程的task_struct,系统堆栈空间和页面表,这意味着上面的程序,我们没有执行count++前,其实子进程和父进程的count指向的是同一块内存。而当子进程改变了变量时候(即对变量进行了写操作),会通过copy_on_write的手段为所涉及的页面建立一个新的副本。

所以当我们执行++count后,这时候的子进程才新建了一个页面复制原来页面的内容,基本资源的复制是必须的,而且是高效的。整体看上去就像是父进程的独立存储空间也复制了一遍。

写入时复制(Copy-on-write)是一个被使用在程式设计领域的最佳化策略。其基础的观念是,如果有多个呼叫者(callers)同时要求相同资源,他们会共同取得相同的指标指向相同的资源,直到某个呼叫者(caller)尝试修改资源时,系统才会真正复制一个副本(private copy)给该呼叫者,以避免被修改的资源被直接察觉到,这过程对其他的呼叫只都是通透的(transparently)。此作法主要的优点是如果呼叫者并没有修改该资源,就不会有副本(private copy)被建立。

第一代Unix系统实现了一种傻瓜式的进程创建:当发出fork()系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为是非常耗时的,因为它需要:

  • 为子进程的页表分配页帧
  • 为子进程的页分配页帧
  • 初始化子进程的页表
  • 把父进程的页复制到子进程相应的页中

这种创建地址空间的方法涉及许多内存访问,消耗许多CPU周期,并且完全破坏了高速缓存中的内容。在大多数情况下,这样做常常是毫无意义的,因为许多子进程通过装入一个新的程序开始它们的执行,这样就完全丢弃了所继承的地址空间。

现在的Linux内核采用一种更为有效的方法,称之为写时复制(Copy On Write,COW)。这种思想相当简单:父进程和子进程共享页帧而不是复制页帧。然而,只要页帧被共享,它们就不能被修改,即页帧被保护。无论父进程还是子进程何时试图写一个共享的页帧,就产生一个异常,这时内核就把这个页复制到一个新的页帧中并标记为可写。原来的页帧仍然是写保护的:当其他进程试图写入时,内核检查写进程是否是这个页帧的唯一属主,如果是,就把这个页帧标记为对这个进程是可写的。

当进程A使用系统调用fork创建一个子进程B时,由于子进程B实际上是父进程A的一个拷贝,

因此会拥有与父进程相同的物理页面.为了节约内存和加快创建速度的目标,fork()函数会让子进程B以只读方式共享父进程A的物理页面.同时将父进程A对这些物理页面的访问权限也设成只读.

这样,当父进程A或子进程B任何一方对这些已共享的物理页面执行写操作时,都会产生页面出错异常(page_fault int14)中断,此时CPU会执行系统提供的异常处理函数do_wp_page()来解决这个异常.

do_wp_page()会对这块导致写入异常中断的物理页面进行取消共享操作,为写进程复制一新的物理页面,使父进程A和子进程B各自拥有一块内容相同的物理页面.最后,从异常处理函数中返回时,CPU就会重新执行刚才导致异常的写入操作指令,使进程继续执行下去.

如果fork简单的vfork()的做法更加火爆,内核连子进程的虚拟地址空间结构也不创建了,直接共享了父进程的虚拟空间,当然了,这种做法就顺水推舟的共享了父进程的物理空间

从运行结果可以看到vfork创建出的子进程(线程)共享了父进程的count变量,2者的count指向了同一个内存,所以子进程修改了count变量,父进程的 count变量同样受到了影响。

  • 由vfork创造出来的子进程还会导致父进程挂起,除非子进程exit或者execve才会唤起父进程
  • 由vfok创建出来的子进程共享了父进程的所有内存,包括栈地址,直至子进程使用execve启动新的应用程序为止
  • 由vfork创建出来的子进程不应该使用return返回调用者,或者使用exit()退出,但是它可以使用_exit()函数来退出

如果我们使用return来退出,你会发现程序陷入一种逻辑混乱的重复vfork状态

参见下面的代码

我们会发现vfork的子进程在使用return后,返回到了调用处,因此父进程又创建出一个新的vfork进程

解决这种问题的方法就是不要在进程中使用return,而是使用exit或者_exit来代替

vfork()用法与fork()相似.但是也有区别,具体区别归结为以下3点

  1. fork() 子进程拷贝父进程的数据段,代码段.vfork() 子进程与父进程共享数据段.|
  2. fork() 父子进程的执行次序不确定.vfork():保证子进程先运行,

vfork()保证子进程先运行,在她调用exec或_exit之后父进程才可能被调度运行。如果在

调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。

在调用exec或_exit之前与父进程数据是共享的,在它调用exec或_exit之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。当需要改变共享数据段中变量的值,则拷贝父进程

vfork用于创建一个新进程,而该新进程的目的是exec一个新进程,vfork和fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,不会复制页表。因为子进程会立即调用exec,于是也就不会存放该地址空间。不过在子进程中调用exec或exit之前,他在父进程的空间中运行。

如果在调用vfork时子进程依赖于父进程的进一步动作,则会导致死锁。由此可见,这个系统调用是用来启动一个新的应用程序。其次,子进程在vfork()返回后直接运行在父进程的栈空间,并使用父进程的内存和数据。这意味着子进程可能破坏父进程的数据结构或栈,造成失败。

为了避免这些问题,需要确保一旦调用vfork(),子进程就不从当前的栈框架中返回,并且如果子进程改变了父进程的数据结构就不能调用exit函数。

子进程还必须避免改变全局数据结构或全局变量中的任何信息,因为这些改变都有可能使父进程不能继续。通常,如果应用程序不是在fork()之后立即调用exec(),就有必要在fork()被替换成vfork()之前做仔细的检查。

因为以前的fork当它创建一个子进程时,将会创建一个新的地址空间,并且拷贝父进程的资源,而往往在子进程中会执行exec调用,这样,前面的拷贝工作就是白费力气了,这种情况下,聪明的人就想出了vfork,它产生的子进程刚开始暂时与父进程共享地址空间(其实就是线程的概念了),因为这时候子进程在父进程的地址空间中运行,所以子进程不能进行写操作,

并且在儿子“霸占”着老子的房子时候,要委屈老子一下了,让他在外面歇着(阻塞),一旦儿子执行了exec或者exit后,相当于儿子买了自己的房子了,这时候就相当于分家了。此时vfork保证子进程先运行,在她调用exec或exit之后父进程才可能被调度运行。

因此vfork设计用以子进程创建后立即执行execve系统调用加载新程序的情形。在子进程退出或开始新程序之前,内核保证了父进程处于阻塞状态

用vfork函数创建子进程后,子进程往往要调用一种exec函数以执行另一个程序,当进程调用一种exec函数时,该进程完全由新程序代换,而新程序则从其main函数开始执行,因为调用exec并不创建新进程,所以前后的进程id 并未改变,exec只是用另一个新程序替换了当前进程的正文,数据,堆和栈段。

clone函数功能强大,带了众多参数,因此由他创建的进程要比前面2种方法要复杂。

clone可以让你有选择性地继承父进程的资源,你可以选择像vfork一样和父进程共享一个虚存空间,从而使创造的是线程,你也可以不和父进程共享,你甚至可以选择创造出来的进程和父进程不再是父子关系,而是兄弟关系。

先有必要说下这个函数的结构

···c

int clone(int (fn)(void ), void *child_stack, int flags, void *arg);

···

这里fn是函数指针,我们知道进程的4要素,这个就是指向程序的指针,就是所谓的“剧本”, child_stack明显是为子进程分配系统堆栈空间(在linux下系统堆栈空间是2页面,就是8K的内存,其中在这块内存中,低地址上放入了值,这个值就是进程控制块task_struct的值),flags就是标志用来描述你需要从父进程继承那些资源, arg就是传给子进程的参数)。

下面是flags可以取得值

  • 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线程标准,子进程与父进程共享相同的线程群

下面的例子是创建一个线程(子进程共享了父进程虚存空间,没有自己独立的虚存空间不能称其为进程)。父进程被挂起当子线程释放虚存资源后再继续执行。

实现方式思路

系统调用服务例程sys_clone, sys_fork, sys_vfork三者最终都是调用do_fork函数完成.

do_fork的参数与clone系统调用的参数类似, 不过多了一个regs(内核栈保存的用户模式寄存器). 实际上其他的参数也都是用regs取的

具体实现的参数不同

clone:

clone的API外衣, 把fn, arg压入用户栈中, 然后引发系统调用. 返回用户模式后下一条指令就是fn.

sysclone: parent_tidptr, child_tidptr都传到了 do_fork的参数中

sysclone: 检查是否有新的栈, 如果没有就用父进程的栈 (开始地址就是regs.esp)

fork, vfork:

服务例程就是直接调用do_fork, 不过参数稍加修改

clone_flags:

sys_fork: SIGCHLD, 0, 0, NULL, NULL, 0

sys_vfork: CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL, 0

用户栈: 都是父进程的栈.

parent_tidptr, child_ctidptr都是NULL.

原文链接:https://blog.csdn.net/gatieme/article/details/51417488

本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com

点赞 0
收藏 0

文章为作者独立观点不代本网立场,未经允许不得转载。