前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Fork三部曲之clone的诞生

Fork三部曲之clone的诞生

作者头像
Linux阅码场
发布2019-09-24 16:51:07
8580
发布2019-09-24 16:51:07
举报
文章被收录于专栏:LINUX阅码场LINUX阅码场

本文fork三部曲的后传,建议先阅读:

在本文中,传统UNIX fork之后,我给出传统的UNIX fork在Linux内核中的变体clone系统调用的精彩。


若要理解fork的原始意义,还是要看Melvin Conway提出fork思想的原始论文 A Multiprocessor System Design:

https://archive.org/details/AMultiprocessorSystemDesignConway1963/page/n7 该论文的核心在于Conway分离了 “进程(process)”“处理器(processpr)” 的概念:

  • 一个进程不必特定于一个处理器上被处理。
  • 一个处理器未必处理特定的进程。
  • 系统中进程数量和处理器数量不需要相等。

fork为上述的核心思想提供了实现的手段。后来fork被引入到UNIX系统,成了创建新进程几十年不变的通用操作。

比较有意思的是,UNIX fork是通过著名的fork-exec序列而闻名于世的,而不是因为其提供的并行多处理手段而闻名于世,这可能是因为在线程概念出现以后,并行处理均由线程担当,也就在没有人记起fork了吧。

如果说一系列进程是 完全可并行 的,那么它们便没有资源是相互依赖的,这便是现代操作系统进程(即process)抽象的基础。可见,基于进程抽象的现代操作系统本身就是一个可并行系统。在一个可并行的系统中,进程之间本就是资源隔离的,如果需要join操作,引入IPC机制便是。 线程概念的出现,就是对UNIX进程抽象的资源如何共享重新解构再重构。

我们看看在线程出现之前,fork提供的并行多处理是多么高效。最典型的例子就是TCP服务编程模型了:

代码语言:javascript
复制
void handle_request(int csd)
{
	...
	// 读取请求
	ret = recv(csd, buf_req, len);
	ret = read(fd, buf_tosend, ret);
	ret = send(csd, buf_tosend, ret);
	close(csd);
}
void server(int sd)
{
	...
	while (1) {
		csd = accept(sd, 10);
		if (fork() == 0) {
			close(sd);
			handle_request(csd); // 可并行处理
		}
	}
}

这几乎成了服务器编程范式,是理解和设计select/poll/epoll程序的前提,也是理解后来Apache Web Server以及Nginx的基础。

以上这段简单代码,请问,用Windows的CreateProcess API如何实现?

不使用线程API,只用进程API,若要并行处理多个请求,CreateProcess需要载入一个磁盘程序映像来执行handle_request,该映像程序写出来可能是下面的样子(这不是最高效的写法,这只是一种直接的写法):

代码语言:javascript
复制
void handle_request(int csd)
{
	...
	// 读取请求
	ret = recv(csd, buf_req, len);
	ret = read(fd, buf_tosend, ret);
	ret = send(csd, buf_tosend, ret);
	close(csd);
}
int main(int argc, char **argv)
{
	char *client_info = argv[1];
	int sd;

	sd = GetOrCreateSocket(client_info);
	handle_request(sd);
}

我们知道载入一个程序的映像开销非常大,但为了并行处理不得不如此,否则Windows就必须串行处理handle_reques和接下来的accept。Windows没有fork,它没有可以实现进程在任意点的分叉的机制。

当然,现实中,Windows可以使用多线程API CreateThread来干这件事。还可以大肆声张多线程要比多进程方案高效。但如果没有多线程,想必Windows面对fork的挑衅只能忍气吞声而兴叹了。

因此,UNIX fork有两个层面的含义:

  1. 创建新进程,fork-exec序列(而不是fork本身)竞争Windows CreateProcess或者POSIX spawn。
  2. 并行多处理,fork作为多进程竞争多线程。

很明显,无论在哪个层面,fork均已落后于对手:

  1. 创建新进程,CreateProcess/spawn剔除了不必要的资源复制操作。
  2. 并行多处理,多线程共享资源替代了昂贵的IPC。

作为多进程的优化或者说替代,多线程的本质和fork的原始意义看起来并无太大的分歧。唯一的区别似乎就是资源共享的深度不同。

fork的原始意义将要在Linux内核task的设计中得到了延续和升华!

Linux内核的设计者似乎在很早以前就意识到了这一点,在很早的年代,Linux内核就没有去设计一个表示进程的结构体,而只设计了一个task_struct(以下简称task),该结构体包含有 让一个指令流能运行所需要的最少的东西! 因此它并不包含特定于进程或者线程概念的字段。

一个或者一组task对象到底是什么,关键看你怎么调配它! 就像使用相同的文字,组合不同,或是诅咒,或是祝福。

一个task对象只是一个原材料,它和其它task对象对资源的共享关系决定了它是什么。

是时候放出这张图了:

一组task对象按照下面的ID类型被标识为不同的实体:

代码语言:javascript
复制
enum pid_type
{
    PIDTYPE_PID,   
    PIDTYPE_TGID, 
    PIDTYPE_PGID,
    PIDTYPE_SID,
    PIDTYPE_MAX
};

关于上图更多的解释,参见下面的文章 朴素的UNIX之-进程/线程模型:

https://blog.csdn.net/dog250/article/details/40208219

对应底层关于task灵活的设计,必须给予应用程序调配它的接口以适应这种灵活。完成这种适配的是Linux的clone系统调用,该系统调用在很早的Linux内核(至少是2.2版本)中就已经存在了:

代码语言:javascript
复制
#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 */ );

/* For the prototype of the raw system call, see NOTES */

可见,参数众多,这里的flags参数就是让调用者控制如何和子进程共享资源的,拥有这种控制权是clone和fork最大的不同:

注意到clone函数的声明依赖于一个宏:

代码语言:javascript
复制
#define _GNU_SOURCE

这意味着clone是非标准的。确实,它只是Linux的一个系统调用。之所以存在这个灵活的clone调用,完全得益于Linux内核底层对task灵活的设计。

在传统UNIX系统或者类UNIX系统,未实现clone。这里面的原因可能是UNIX从一开始就明确定义了进程,到了后来,当UNIX不得不支持线程的时候,就要引入一个所谓 轻量级进程 的新概念,意思是可以共享某些资源的进程。参见名牌UNIX Solaris中lwp的实现。

在这些老牌Unix系统中,一开始过重的进程概念在引入多线程机制时造成了阻碍。然而对于Linux,为了支持线程引入新的数据结构完全没有必要。

虽然人们经常说clone调用创建的是轻量级进程,但也只是称呼罢了,Linux内核内部没有一个表示轻量级进程的结构体。

Linux内核在底层task设计以及系统调用接口如此这般的设计,注定它实现Posix线程规范是超级简单的。一个clone参数就能搞定:

注意后面那个 "(since Linux 2.4.0)" 注解,这意味着在2.4内核之前,Linux内核是不支持Posix线程的。但是这里说的不支持,只是无法在内核级实现Posix规范要求线程必须遵循的语义,并不是说在并行多处理机制上不支持,至于说POSIX线程的语义,在用户态支持也是一个办法,这都是2.4内核之前的事了。

2.4内核之后,Linux对线程的支持就完全是内核级的了。pthread库完全基于CLONE_THREAD实现。CLONE_THREAD的注释参见上图所示的clone manual。

具体如何创建一个线程呢?底层到底发生了什么呢?参见下面最简单demo:

代码语言:javascript
复制
#include <pthread.h>
#include <stdio.h>

void *func(void *unused)
{
	printf("sub thread\n");
	return (void *)123;
}

int main(int argc, char **argv)
{
	pthread_t t1;
	void *p;

	pthread_create(&t1, NULL, *func, NULL);
	pthread_join(t1, &p);
	printf("main thread:%d\n", (int)p);
	return 0;
}

关于线程,重要的有两点,即创建和销毁。让我们来strace一下:

其中,clone系统调用的flags参数的含义大致可以表述如下:

  • 黄色:指示都共享哪些资源,MM,FILES,FS等
  • 红色:实现POSIX线程的语义,比如共享进程PID,信号传递这些。

clone之后,就创建了一个线程。线程执行func之后便退出了。问题是,线程是如何退出的呢?

对于普通的C程序,我们知道main函数返回到了C库,而C库在main返回后会调用exit退出程序,而对于多线程程序,在编译代码的时候,我们显式链接了libpthread,那么类似C库的事情在多线程程序里就libpthread库代劳了。

大致的pthread_create应该是这个样子:

代码语言:javascript
复制
void clone_func(Thread *thread)
{
	ret = thread->fn(...);
	exit(ret);
}
int pthread_create(..., fn, ...)
{
	thread = malloc(sizeof(&thread));
	thread->fn = fn;
	ret = clone(clone_func, &thread);
	return ERR_NO(ret);
}

我们通过上面的strace可以看出,线程退出使用exit系统调用,而主进程退出则使用exit_group系统调用,二者的区别更多的是Posix进程/线程的语义上的,严格来讲,exit系统调用仅仅退出当前的task_struct,而exit_group则是退出当前task_struct所在进程的所有task_struct,对于多线程程序,它当然就是退出所有的线程了。

这就是Linux内核级线程的实现原理了。

但是,clone系统调用远不是仅仅实现多线程这么单一,它还可以优化UNIX fork的另一个层面。按照传统UNIX fork在两个层面的效用,Linux clone的对应描述如下:

  1. 在执行新进程层面,clone可以仅仅CLONE_VM实现轻量级进程快速exec以避免不必要的资源拷贝。
  2. 在并行多处理层面,如前所述,clone的CLONE_XX联合CLONE_THREAD可以实现内核级POSIX线程。

本文作为关于fork的后传,再不要说fork的不是了,fork的思想最终被Linux所继承和发扬,一切回归到了Conway在1963年的原始论文,并行多处理,终于在Linux clone系统调用上得到了落实:

  • clone可以创建多线程并行执行序列。
  • clone创建新进程,减少不必要的资源复制。

好了,这就是我要为你讲述的 “fork” 的故事。


浙江温州皮鞋湿,下雨进水不会胖。

(完)

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-09-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Linux阅码场 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档