Linux内核分析及应用(txt+pdf+epub+mobi电子书下载)


发布时间:2020-10-13 21:13:14

点击下载

作者:陈科

出版社:机械工业出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

Linux内核分析及应用

Linux内核分析及应用试读:

前言

随着计算机技术的发展,开发人员想要根据业务需求写出相关实现代码还是比较容易的,因为已经有了很多工具、组件、库等可帮助我们实现功能。开发人员很少会自己裸写代码,不会从底层到上层全部由自己来实现。于是,很多开发人员一旦遇到程序出现问题就会茫然失措,不知道该如何处理,甚至故障诊断和分析都成了一门高深的技术难题。

我们做任何一件事情都应该知其然,并知其所以然。操作系统是计算机的基础,所有的应用程序都是运行在操作系统之上的,所以,不管开发人员使用什么语言,开发什么行业的应用,都应该了解操作系统的原理与实现细节。

本人因为长期从事系统架构相关的工作,在涉及一些中间件或者基础组件的研发工作过程中,经常会与操作系统打交道,特别是Linux内核。我个人认为,所有应用开发人员都应该了解操作系统的实现原理和思路。Linux是人类工程史上的一个奇迹(那么大的工程,那么多人在网络上维护,能保持那么高的可用性),Linux内核作为一个开放源码的工程,在很多方面值得我们学习和借鉴。其实在工程领域,很多问题的解题思路是类似的,掌握内核的实现,对于我们更好地编写高性能、高可靠性的程序有很大帮助,也更加利于千里定位故障,秒杀Bug。Linux体系结构

操作系统是所有应用程序生长的河床,它帮我们屏蔽各种硬件的细节,并且抽象出各种系统调用供应用开发人员来使用。

下面来介绍一下本书将要介绍的Linux内核的体系结构(图1),以便于后续章节展开分析。图1 Linux内核的体系结构

整个Linux内核可以分为4层:

·驱动管理层,驱动并管理外部一些硬件设备,例如磁盘、网卡等。

·工具层,内核抽象出一些通用组件便于自己使用,例如并发管理中的一些锁、per-cpu变量等工具,另外还有中断机制,也给进程管理、信号处理等提供了基础功能。

·系统能力层,操作系统的功能包括进程管理、内存管理、文件系统、I/O管理、网络等,这些功能都是基于工具层和驱动管理层提供的能力来构建的。

·系统调用接口层(syscall),给应用程序开发人员提供相关接口。因为系统调用的使用成本较高,参数也比较多,需要对内核有较多了解,所以,又抽象出一些libc等库函数来封装系统调用,应用开发人员一般都是通过libc等库来与内核打交道的。推荐预备知识

理论上说,只需具备一门编程语言的开发基础就能阅读本书,不过,为了更好地研究操作系统,我推荐大家先阅读下面的书籍:

·《80x86汇编语言程序设计》(沈美明等),与CPU打交道最好的方式还是汇编语言,另外,了解汇编语言也方便更好地掌握计算机体系结构,进而深入理解系统的工作原理。

·《Intel开发手册卷3》,Intel公司的开发手册,可以让读者了解CPU的工作原理、基本指令集等。CPU相关的功能也是内核最为重要的部分之一。

·《自己动手编写操作系统》(于渊),该书通过编写一个简易系统,让读者更加了解硬件的工作原理。

·《Linux内核完全注释》(v0.11)(赵炯),通过对0.11版本的Linux学习,可以了解早期的内核架构,这对学习新版本内核也有很大的帮助。

·《软件调试的艺术》(马特洛夫),作为一名程序员,掌握基本的调试技能是必须的。

·《程序员的自我修养:链接、装载与库》(潘爱民等),这本书有助于掌握编译、链接的原理,对了解操作系统编译、运行以及应用程序的装载原理有很大帮助。

当然,掌握C语言也是必须的,毕竟Linux内核是用C语言开发的,如果是C语言新手,可以先阅读Memcached的源码,因为作者的代码写得比较清晰,易于理解,初学者都可以轻松上手。本书章节概述

由于篇幅有限,本书并没有详细介绍Linux内核的所有知识点,比如系统的启动过程,虽然对于一个内核的实现来讲,系统启动是非常重要的,但本书考虑的场景都是围绕系统启动之后提供的功能来展开的,所以本书没有包括这部分内容。

本书共分为8章,分别介绍Linux操作系统的各个模块。对于Linux内核来讲,各个模块之间虽然都是紧密结合的,但是从系统领域模型的角度,每一章都可以独立展开,读者既可以从头开始阅读,也可以选择自己感兴趣的章节进行学习。

第1章介绍进程和线程的概念、历史、实现原理、应用场景等,然后介绍Linux对进程和线程的实现,以及调度的机制、进程CPU亲和度等,并分析了Memcached线程池模型和Nginx工作进程池模型的实现,最后介绍了进程调试分析监控等工具的用法,包括gdb、coredump、strace、SystemTap、DTrace等调试工具。

第2章介绍并发的概念及其引发的相关问题,接着介绍操作系会在哪些场景遇到并发,进而分析Linux中的并发相关工具,如atomicspin_lock、semaphore、mutex、读写锁、per-cpu、抢占、内存屏障、RCU机制,最后介绍常见开源软件中的并发问题分析,如Nginx的原子性、Memcached的互斥锁、Linux中惊群问题分析、解决MyCat中的同步问题、伪共享问题解决方案等。

第3章首先介绍内存在体系结构中的作用,以及在使用中会遇到什么问题,接着介绍MMU的内存管理机制、线性地址、物理地址、虚拟地址等。接下来分析Linux是如何进行内存管理的,包括整体架构以及伙伴算法、slab分配器、kmalloc、vmalloc、mallc等。Linux栈内存如何分配,对于内核栈和线程栈Linux又是如何区分和管理的。最后介绍了Memcached和Redis是如何管理内存的。

第4章首先介绍x86系统的中断机制,以及为什么要引入中断机制,接着介绍Linux系统如何对中断机制进行封装和实现,并且介绍为加速中断的处理过程,Linux引入的机制,如软中断、tasklet、工作队列等,最后介绍系统调用、时钟中断、信号处理机制等实现方式。

第5章阐述了I/O在计算机体系结构中的重要性,以及I/O产生的全过程,还介绍I/O调度器和多队列机制。最后介绍一些开源系统和操作系统中I/O相关调用的实现,比如:I/O多路复用epoll调用、Redis对epoll的封装、Nginx读文件异步I/O、零拷贝技术,主要围绕mmap和sendfile的比对以及Mongodb、Kafka对零拷贝技术的使用等。

第6章以vfs为切入点,介绍Linux文件系统的整体架构以及文件系统的核心概念,并且介绍文件系统的主要功能:如安装、文件查找、读写等,简单介绍ext4文件系统,最后介绍TFS小文件系统的设计思路。

第7章介绍Intel VT的硬件虚拟化技术,以及Linux KVM模块等虚拟化技术,然后介绍chroot、namespace、cgroup等容器虚拟化底层技术在Linux上的实现,最后,着重分析新版Docker容器拆分后,容器化模块containerd的实现。

第8章围绕数据如何在Linux网络层流转来介绍Linux网络层的控制机制,首先简单介绍lvs如何在netfilter上进行定制,最后介绍Nginx服务器socket监听初始化的过程。

本书精选了大量案例,相关代码可以下载,地址为https://github.com/lingq1818/analysis_linux,其中包括本书使用的Linux内核源代码。致谢

感谢我曾经的同事和领导们:庄涛、胡志强、何崚、吴国庆、刘晓飞、陈洁梅、郭秀军,等等,是你们对我工作的帮助和支持,才让我有机会深入了解Linux并有了总结思路。感谢吴怡编辑的辛勤工作和不断督促,让本书的出版成为可能。同时还要感谢许多我不知道名字的幕后工作人员为本书付出的努力。

最后,感谢我的家人,是你们默默地支持和付出,才能让我在工作上不断前进,你们的爱永远是我前进的动力。第1章进程与线程只要是计算机科班出身的技术人员,肯定都学过现代操作系统课程。一般在操作系统的书中都会有这样的定义:简单来说,进程就是在操作系统中运行的程序,是操作系统资源管理的最小单位。一个进程可以管理多个线程,线程相对轻量,可以共享进程地址空间。我在很多次面试的时候,向求职者提问过进程和线程在Linux中到底有什么区别,不只是科班出身的应届生,连工作多年的老手,也有很多回答不准确。传统的教育缺乏实践环节,而计算机恰恰是一个实践性很强的学科,假如只是知道一个概念,却不知道它具体在代码中的表现形式以及背后的实现原理,那么知道与不知道这个概念又有何分别呢?那么,线程和进程到底有什么区别呢?既然进程可以管理线程,是否说明进程就特别牛呢?另外,搞出这些概念到底要解决什么问题,是否还具有副作用呢?本章将对这些问题一一解答。1.1进程和线程的概念

我觉得不管做什么工作,都需要搞明白所面临工作的过去、现在和未来。我认为不懂历史的程序员肯定写不出好代码。因为不知道这个技术被创造出来到底意味着什么,也无法理解未来这个技术要向哪里发展,仅仅是解决当下的问题,修修补补,做一天和尚撞一天钟,仅此而已。下面我们就介绍进程的历史。1.1.1 进程的历史

计算机发明出来是做逻辑运算的,但是当初计算机都是大型机,造价昂贵,只有有钱的政府机构、著名大学的数据中心才会有,一般人接触不到。大家要想用,要去专门的机房。悲催的是,那时候代码还是机器码,直接穿孔把程序输入到纸带上面,然后再拿去机房排队。那时候的计算机也没什么进程管理之类的概念,它只知道根据纸带里的二进制数据进行逻辑运算,一个人的纸带输入完了,就接着读取下一个人的纸带,要是程序有bug,不好意思,只有等到全部运算结束之后才能得到结果,然后回家慢慢改。

为了改进这种排队等候的低效率问题,就有人发明了批处理系统。以前只能一个一个提交程序,现在好了,可以多人一起提交,计算机会集中处理,至于什么时候处理完,回家慢慢等吧。或者你可以多写几种可能,集中让计算机处理,总有一个结果是好的。

懒人总会推动科技进步,为了提升效率,机器码就被汇编语言替代了,从而再也不用一串串二进制数字来写代码了。便于记忆的英文指令会极大提升效率。然后,进程管理这样的概念也被提出来了,为什么要提呢?因为当程序在运算的时候,不能一直占用着CPU资源,有可能此时还会进行写磁盘数据、读取网络设备数据等,这时候完全可以把CPU的计算资源让给其他进程,直到数据读写准备就绪后再切换回来。所以,进程管理的出现也标志着现代操作系统的进步。那么既然进程是运行中的程序,那么,到底什么是程序呢?运行和不运行又有什么区别呢?

先说程序,既然程序是人写的,那么最终肯定会生成可执行文件,保存在磁盘里,而且这个文件可能会很大,有时候不一定是一个文件,可能会有多个文件,甚至文件夹,其包含图片、音频等各种数据。然而,CPU做逻辑运算的每条指令是从内存中读取的,所以运行中的程序可以理解为内存中的代码指令和运行相关的数据被CPU读写并计算的过程。我们都知道内存的大小是有限的,所以很可能装不下磁盘中的整个程序。因此内存中运行的是当下需要运行的部分程序数据,等运算完就会继续读取后面一部分磁盘数据到内存,并继续进行运算。

一个进程在运行的过程中,不可能一直占据着CPU进行逻辑运算,中间很可能在进行磁盘I/O或者网络I/O,为了充分利用CPU运算资源,有人设计了线程的概念。我认为线程最大的特点就是和创建它的进程共享地址空间(关于地址空间的概念大家可以在第3章了解更多)。这时候有人就会认为,要提升CPU的利用率,开多个进程也可以达到,但是开多个进程的话,进程间通信又是个麻烦的事情,毕竟进程之间地址空间是独立的,没法像线程那样做到数据的共享,需要通过其他的手段来解决,比如管道等。图1-1描述了进程和线程的区别。图1-1 进程和线程的区别1.1.2 线程的不同玩法

针对线程现在又有很多玩法,有内核线程、用户级线程,还有协程。下面简单介绍这些概念。

一般操作系统都会分为内核态和用户态,用户态线程之间的地址空间是隔离的,而在内核态,所有线程都共享同一内核地址空间。有时候,需要在内核态用多个线程进行一些计算工作,如异步回调场景的模型,就可以基于多个内核线程进行模拟,比如AIO机制,假如硬件不提供某种中断机制的话,那么就只能通过线程自己去后台模拟了,图1-2说明了有中断机制的写磁盘后回调和没有中断机制的写磁盘后线程模拟异步回调。图1-2 两种异步回调场景

在用户态,大多数场景下业务逻辑不需要一直占用CPU资源,这时候就有了用户线程的用武之地。

不管是用户线程还是内核线程,都和进程一样,均由操作系统的调度器来统一调度(至少在Linux中是这样子)。所以假如开辟太多线程,系统调度的开销会很大,另外,线程本身的数据结构需要占用内存,频繁创建和销毁线程会加大系统的压力。线程池就是在这样的场景下提出的,图1-3说明了常见的线程池实现方案,线程池可以在初始化的时候批量创建线程,然后用户后续通过队列等方式提交业务逻辑,线程池中的线程进行逻辑的消费工作,这样就可以在操作的过程中降低线程创建和销毁的开销,但是调度的开销还是存在的。

在多核场景下,如果是I/O密集型场景,就算开多个线程来处理,也未必能提升CPU的利用率,反而会增加线程切换的开销。另外,多线程之间假如存在临界区或者共享数据,那么同步的开销也是不可忽视的。协程恰恰就是用来解决该问题的。协程是轻量级线程,在一个用户线程上可以跑多个协程,这样就可以提升单核的利用率。在实际场景下,假如CPU有N个核,就只要开N+1个线程,然后在这些线程上面跑协程就行了。但是,协程不像进程或者线程,可以让系统负责相关的调度工作,协程是处于一个线程当中的,系统是无感知的,所以需要在该线程中阻塞某个协程的话,就需要手工进行调度。假如需要设计一套通用的解决方案,那么就需要一番精心的设计。图1-4是一种简单的用户线程上的协程解决方案。图1-3 线程池实现原理图1-4 协程的实现方案

要在用户线程上实现协程是一件很难受的事情,原理类似于调度器根据条件的改变不停地调用各个协程的callback机制,但是前提是大家都在一个用户线程下。要注意,一旦有一个协程阻塞,其他协程也都不能运行了。因此要处理好协程。

下面我们来看一段PHP代码,通过生产者-消费者程序来模拟实现协程的例子:import timedef consumer() // 消费者 r = '' while True: n = yield r // yield条件 if not n: return print('[CONSUMER] Consuming %s...' % n) time.sleep(1) r = '200 OK'def produce(c): // 生产者 c.next() n = 0 while n < 5: n = n + 1 print('[PRODUCER] Producing %s...' % n) r = c.send(n) print('[PRODUCER] Consumer return: %s' % r) c.close()if __name__=='__main__': c = consumer() produce(c)

执行结果:[PRODUCER] Producing 1...[CONSUMER] Consuming 1...[PRODUCER] Consumer return: 200 OK[PRODUCER] Producing 2...[CONSUMER] Consuming 2...[PRODUCER] Consumer return: 200 OK[PRODUCER] Producing 3...[CONSUMER] Consuming 3...[PRODUCER] Consumer return: 200 OK[PRODUCER] Producing 4...[CONSUMER] Consuming 4...[PRODUCER] Consumer return: 200 OK[PRODUCER] Producing 5...[CONSUMER] Consuming 5...[PRODUCER] Consumer return: 200 OK

以上代码中,produce(生产者)会依次生产5份数据n,并且发送给consumer(消费者),只有消费者执行完之后,生产者才会再次生产数据。可以把produce和cosumer理解为两个协程,其中关键点是通过yield关键字来控制消费者,命令yield r会暂停消费者直到r被传递过来为止。注意 关于yield关键字,可以参考PHP手册:http://php.net/manual/zh/language.generators.syntax.php生成器函数的核心是yield关键字。它最简单的调用形式看起来像一个return声明,不同之处在于普通return会返回值并终止函数的执行,而yield会返回一个值给循环调用此生成器的代码,并且只是暂停执行生成器函数。

最后我们进行一下总结,多进程的出现是为了提升CPU的利用率,特别是I/O密集型运算,不管是多核还是单核,开多个进程必然能有效提升CPU的利用率。而多线程则可以共享同一进程地址空间上的资源,为了降低线程创建和销毁的开销,又出现了线程池的概念,最后,为了提升用户线程的最大利用效率,又提出了协程的概念。1.2Linux对进程和线程的实现

通过上一节的介绍,大家应该大致了解了进程和线程在操作系统中的概念和玩法,那么对应到具体的Linux系统中,是否就如上面描述的那样呢?下面来分析Linux中对进程和线程的实现。为了便于理解,首先通过图1-5来简单介绍Linux进程相关的知识结构。

从图中可以发现,进程和线程(包括内核线程)的创建,都是通过系统调用来触发的,而它们最终都会调用do_fork函数,系统调用通过libc这样的库函数封装后提供给应用层调用,进程创建后会产生一个task_struct结构,schedule函数会通过时钟中断来触发调度。后面会进行具体的分析。图1-5 Linux进程相关的知识结构1.2.1 Linux中的进程实现

Linux进程的创建是通过系统调用fork和vfork来实现的,参考内核源码/linux-4.5.2/kernel/fork.c:fork:SYSCALL_DEFINE0(fork){… return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);…}vfork:SYSCALL_DEFINE0(vfork){ return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL, 0);}注意 fork和vfork最终都调用do_fork函数,只是传入的clone_flags参数不同而已,参见表1-1。表1-1 clone_flags的参数及说明

因为进程创建的核心就是do_fork函数,所以来看一下它的相关参数: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)

其中:

·clone_flags:创建子进程相关的参数,决定了父子进程之间共享的资源种类。

·stack_start:进程栈开始地址。

·stack_size:进程栈空间大小。

·parent_tidptr:父进程的pid。

·child_tidptr:子进程的pid。

·tls:线程局部存储空间的地址,tls指thread local Storage。

图1-6为do_fork函数的整个执行流程,在这个执行过程当中,比较关键的是调用copy_process函数,成功后创建子进程,然后在后面就可以获取到pid。另外,我们在这里也发现了fork和vfork的一个区别,vfork场景下父进程会先休眠,等唤醒子进程后,再唤醒父进程。大家可以想一想,这样做的好处是什么呢?我个人认为在vfork场景下,子进程被创建出来时,是和父进程共享地址空间的(这个后面介绍copy_process步骤的时候可以进行验证),并且它是只读的,只有执行exec创建新的内存程序映象时才会拷贝父进程的数据创建新的地址空间,假如这个时候父进程还在运行,就有可能产生脏数据或者发生死锁。在还没完全让子进程运行起来的时候,让其父进程休息是个比较好的办法。图1-6 do_fork函数执行流程

现在已经知道了创建子进程的时候,copy_process这个步骤很重要,所以,我用图1-7总结了其主要的执行流程,这段代码非常长,大家可以自己阅读源码,这里只捡重点的讲。copy_process先一模一样地拷贝一份父进程的task_struct结构,并通过一些简单的配置来初始化,设置好调度策略优先级等参数之后,一系列的拷贝函数就会开始执行,这些函数会根据clone_flags中的参数进行相应的工作。图1-7 copy_process执行流程

主要参数说明如下:

1)copy_semundo(clone_flags,p); 拷贝系统安全相关的数据给子进程,如果clone_flags设置了CLONE_SYSVSEM,则复制父进程的sysvsem.undo_list到子进程;否则子进程的tsk->sysvsem.undo_list为NULL。

2)copy_files(clone_flags,p); 如果clone_flags设置了CLONE_FILES,则父子进程共享相同的文件句柄;否则将父进程文件句柄拷贝给子进程。

3)copy_fs(clone_flags,p); 如果clone_flags设置了CLONE_FS,则父子进程共享相同的文件系统结构体对象;否则调用copy_fs_struct拷贝一份新的fs_struct结构体,但是指向的还是进程0创建出来的fs,并且文件系统资源是共享的。

4)copy_sighand(clone_flags,p); 如果clone_flags设置了CLONE_SIGHAND,则增加父进程的sighand引用计数;否则(创建的必定是子进程)将父进程的sighand_struct复制到子进程中。

5)copy_signal(clone_flags,p); 如果clone_flags设置了CLONE_THREAD(是线程),则增加父进程的sighand引用计数;否则(创建的必定是子进程)将父进程的sighand_struct复制到子进程中。

6)copy_mm(clone_flags,p); 如果clone_flags设置了CLONE_VM,则将子进程的mm指针和active_mm指针都指向父进程的mm指针所指结构;否则将父进程的mm_struct结构复制到子进程中,然后修改当中属于子进程而有别于父进程的信息(如页目录)。

7)copy_io(clone_flags,p); 如果clone_flags设置了CLONE_IO,则子进程的tsk->io_context为current->io_context;否则给子进程创建一份新的io_context。

8)copy_thread_tls(clone_flags,stack_start,stack_size,p,tls); 其中需要重点关注copy_mm和copy_thread_tls这两个步骤,copy_mm进行内存地址空间的拷贝,copy_thread_tls进行栈的分配。

1.写时复制

copy_mm的主要工作就是进行子进程内存地址空间的拷贝,在copy_mm函数中,假如clone_flags参数中包含CLONE_VM,则父子进程共享同一地址空间;否则会为子进程新创建一份地址空间,代码如下:if (clone_flags & CLONE_VM) { // vfork 场景下,父子进程共享虚拟地址空间 atomic_inc(&oldmm->mm_users); mm = oldmm; goto good_mm;}retval = -ENOMEM;mm = dup_mm(tsk);if (!mm) goto fail_nomem;

dup_mm函数虽然给进程创建了一个新的内存地址空间(关于进程地址空间的概念会在第3章再进行深入分析),但在复制过程中会通过copy_pte_range调用copy_one_pte函数进行是否启用写时复制的处理,代码如下:if (is_cow_mapping(vm_flags)) { ptep_set_wrprotect(src_mm, addr, src_pte); pte = pte_wrprotect(pte);}

如果采用的是写时复制(Copy On Write),若将父子页均置为写保护,即会产生缺页异常。缺页异常最终会调用do_page_fault,do_page_fault进而调用handle_mm_fault。一般所有的缺页异常均会调用handle_mm_fault的核心代码如下:pud = pud_alloc(mm, pgd, address);if (!pud) return VM_FAULT_OOM;pmd = pmd_alloc(mm, pud, address);if (!pmd) return VM_FAULT_OOM;pte = pte_alloc_map(mm, pmd, address);if (!pte) return VM_FAULT_OOM;

handle_mm_fault最终会调用handle_pte_fault,其主要代码如下:if (flags & FAULT_FLAG_WRITE) { if (!pte_write(entry)) return do_wp_page(mm, vma, address, pte, pmd, ptl, entry); entry = pte_mkdirty(entry);}

即在缺页异常中,如果遇到写保护,则会调用do_wp_page,这里面会处理上面所说的写时复制中父子进程区分的问题。

最后通过图1-8来说明fork和vfork在地址空间分配上的区别。图1-8 fork和vfork的区别

2.进程栈的分配

copy_process中另一个比较重要的函数就是copy_thread_tls,在创建子进程的过程中,进程的内核栈空间是随进程同时分配的,结构如图1-9所示。代码如下:struct pt_regs *childregs = task_pt_regs(p); p->thread.sp = (unsigned long) childregs; p->thread.sp0 = (unsigned long) (childregs+1);

其中,task_pt_regs(p)的代码如下:#define task_pt_regs(task) \({ \ unsigned long __ptr = (unsigned long)task_stack_page(task); \ __ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING; \ ((struct pt_regs *)__ptr) - 1; \})

childregs=task_pt_regs(p);实际上就是childregs=((struct pt_regs*)(THREAD_SIZE+(unsigned long)p))-1;,也就是说,childregs指向的地方是:子进程的栈顶再减去一个sizeof(struct pt_regs)的大小。图1-9 进程的内核栈空间分配1.2.2 进程创建之后

通过上面的分析我们知道,不管是fork还是vfork,创建一个进程最终都是通过do_fork函数来实现的。

在进程刚刚创建完成之后,子进程和父进程执行的代码是相同的,并且子进程从父进程代码的fork返回处开始执行,这个代码可以参考copy_thread_tls函数的实现:childregs->ax = 0;p->thread.ip = (unsigned long) ret_from_fork;

同时可以发现,上面代码返回的pid为0。

假如创建出来的子进程只是和父进程做一样的事情,那能做的事情就很有限了,所以Linux另外提供了一个系统调用execve,该调用可以替换掉内存当中的现有程序,以达到执行新逻辑的目的。execve的实现在/linux-4.5.2/fs/exec.c文件中,下面简单来分析它的实现,该系统调用声明为:SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user *, envp){ return do_execve(getname(filename), argv, envp);}

execve通过do_execve函数最终调用了do_execveat_common,下面是其流程的说明:

1)file=do_open_execat(fd,filename,flags);打开可执行文件。

2)初始化用于在加载二进制可执行文件时存储与其相关的所有信息的linux_binprm数据结构:bprm_mm_init(bprm);,其中会初始化一份新的mm_struct给该进程使用。

3)prepare_binprm(bprm);从文件inode中获取信息填充binprm结构,检查权限,读取最初的128个字节(BINPRM_BUF_SIZE)。

4)将运行所需的参数和环境变量收集到bprm中:retval = copy_strings_kernel(1, &bprm->filename, bprm);if (retval < 0) goto out;bprm->exec = bprm->p;retval = copy_strings(bprm->envc, envp, bprm);if (retval < 0) goto out;retval = copy_strings(bprm->argc, argv, bprm);if (retval < 0)goto out;

5)retval=exec_binprm(bprm);该过程调用search_binary_handler加载可执行文件。注意 Linux可执行文件的装载和运行必须遵循ELF(Executable and Linkable Format)格式的规范,关于可运行程序的装载是个独立的话题,这里不再进行展开。大家有兴趣可以阅读《程序员的自我修养:链接、装载与库》。1.2.3 内核线程和进程的区别

前面我们介绍了内核线程的概念,现在来分析Linux对内核线程的实现,在Linux中,创建内核线程可以通过create_kthread来实现,其代码如下:static void create_kthread(struct kthread_create_info *create){ int pid;... pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES | SIGCHLD);...}

kernel_thread也会和fork一样最终调用_do_fork函数,所以该函数的实现在/linux-4.5.2/kernel/fork.c文件中:pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags){ return _do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn, (unsigned long)arg, NULL, NULL, 0);}

通过这个函数可以创建内核线程,运行一个指定函数fn。

但是这个fn是如何运行的呢?为什么do_fork函数的stack_start和stack_size参数变成了fn和arg呢?

继续往下看,因为我们知道do_fork函数最终会调用copy_thread_tls。在内核线程的情况下,代码如下:if (unlikely(p->flags & PF_KTHREAD)) { // 内核线程 memset(childregs, 0, sizeof(struct pt_regs)); p->thread.ip = (unsigned long) ret_from_kernel_thread; task_user_gs(p) = __KERNEL_STACK_CANARY; childregs->ds = __USER_DS; childregs->es = __USER_DS; childregs->fs = __KERNEL_PERCPU; childregs->bx = sp; // 函数 childregs->bp = arg;// 传参 childregs->orig_ax = -1; childregs->cs = __KERNEL_CS | get_kernel_rpl(); childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED; p->thread.io_bitmap_ptr = NULL; return 0;}

这里把ip设置成了ret_from_kernel_thread,函数指针传递给了bx寄存器,参数传递给了bp寄存器。

然后继续来看ret_from_kernel_thread做了些什么:ENTRY(ret_from_kernel_thread) pushl %eax call schedule_tail GET_THREAD_INFO(%ebp) popl %eax pushl $0x0202 // 重置内核eflags寄存器 popfl movl PT_EBP(%esp), %eax call *PT_EBX(%esp) // 这里就是调用fn的过程 movl $0, PT_EAX(%esp)… movl %esp, %eax call syscall_return_slowpath jmp restore_allENDPROC(ret_from_kernel_thread)

通过对内核线程的分析可以发现,内核线程的地址空间和父进程是共享的(CLONE_VM),它也没有自己的栈,和整个内核共用同一个栈,另外,可以自己指定回调函数,允许线程创建后执行自己定义好的业务逻辑。可以通过ps-fax命令来观察内核线程,下面显示了执行ps-fax命令的结果,在[]号中的进程即为内核线程:chenke@chenke1818:~$ ps -fax PID TTY STAT TIME COMMAND 2 ? S 0:34 [kthreadd] 3 ? S 1276:07 \_ [ksoftirqd/0] 5 ? S< 0:00 \_ [kworker/0:0H] 6 ? S 2:38 \_ [kworker/u4:0] 7 ? S 396:12 \_ [rcu_sched] 8 ? S 0:00 \_ [rcu_bh] 9 ? S 12:51 \_ [migration/0]1.2.4 用户线程库pthread

在libc库函数中,pthread库用于创建用户线程,其代码在libc目录下的nptl中。该函数的声明为:int __pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

libc库为了考虑不同系统兼容性问题,里面有一堆条件编译信息,这里忽略了这些信息,就写了简单地调用pthread库创建一个线程来测试:#include #include #include #include void* test_fn(void* arg){ printf("hello pthread.\n"); sleep(5); return((void *)0);}int main(int argc,char **argv){ pthread_t id; int ret; ret = pthread_create(&id,NULL,test_fn,NULL); if(ret != 0) { printf("create pthread error!\n"); exit(1); } printf("in main process.\n"); pthread_join(id,NULL); return 0;}

用gcc命令生成可执行文件后用strace来跟踪系统调用:gcc -g -lpthread -Wall -o test_pthread test_pthread.cstrace ./test_pthread.cmmap(NULL, 8392704, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_ STACK,-1, 0) = 0x7fb6ade8a000brk(0) = 0x93d000brk(0x95e000) = 0x95e000mprotect(0x7fb6ade8a000, 4096, PROT_NONE) = 0clone(child_stack=0x7fb6ae689ff0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_ SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_ CHILD_CLEARTID, parent_tidptr=0x7fb6ae68a9d0, tls=0x7fb6ae68a700, child_tidptr=0x7fb6ae68a9d0) = 6186

分析上面strace产生的结果,可以得到pthread创建线程的流程,大概如下:

1)mmap分配用户空间的栈大小。

2)mprotect设置内存页的保护区(大小为4KB),这个页面用于监测栈溢出,如果对这片内存有读写操作,那么将会触发一个SIGSEGV信号。

3)通过clone调用创建线程。

通过对pthread分析,我们也可以知道用户线程的堆栈可以通过mmap从用户空间自行分配。

分析Linux中对进程和线程创建的几个系统调用可发现,创建时最终都会调用do_fork函数,不同之处是传入的参数不同(clone_flags),最终结果就是进程有独立的地址空间和栈,而用户线程可以自己指定用户栈,地址空间和父进程共享,内核线程则只有和内核共享的同一个栈,同一个地址空间。当然不管是进程还是线程,do_fork最终会创建一个task_struct结构。1.3进程的调度

在一个CPU中,同一时刻最多只能支持有限的进程或者线程同时运行(这取决于CPU核数量),但是在一个运行的操作系统上往往可以运行很多进程,假如运行的进程占据CPU进程时间很长,就有可能让其他进程饿死。为了解决这种问题,操作系统引入了进程调度器来进行进程的切换,目的是轮流让其他进程获取CPU资源。1.3.1 进程调度机制的架构

在每个进程运行完毕时,系统可以进行调度的工作,但是系统不可能总是在进程运行完才调度,不然其他进程估计还没被调度就饿死了。系统还需要一个重要的机制:中断机制,来周期性地触发调度算法进行进程的切换。

Linux进程的切换是通过schedule函数来完成的,其主要逻辑由_schedule函数实现:static void __sched notrace __schedule(bool preempt){ // 阶级1 struct task_struct *prev, *next; unsigned long *switch_count; struct rq *rq; int cpu; cpu = smp_processor_id(); // 获取当前CPU的id rq = cpu_rq(cpu); rcu_note_context_switch(); // 标识当前CPU发生任务切换,通过RCU更新状态 prev = rq->curr; … //阶段2 switch_count = &prev->nivcsw; if (!preempt && prev->state) { if (unlikely(signal_pending_state(prev->state, prev))) { prev->state = TASK_RUNNING; } else { deactivate_task(rq, prev, DEQUEUE_SLEEP); prev->on_rq = 0; if (prev->flags & PF_WQ_WORKER) { struct task_struct *to_wakeup; to_wakeup = wq_worker_sleeping(prev, cpu); if (to_wakeup) try_to_wake_up_local(to_wakeup); } } switch_count = &prev->nvcsw; } // 阶段3 if (task_on_rq_queued(prev)) update_rq_clock(rq); // 阶段4 next = pick_next_task(rq, prev); // 选取下一个将要执行的进程 clear_tsk_need_resched(prev); clear_preempt_need_resched(); rq->clock_skip_update = 0; if (likely(prev != next)) { rq->nr_switches++; rq->curr = next; ++*switch_count; … // 阶段5 rq = context_switch(rq, prev, next); //进行进程上下文切换 cpu = cpu_of(rq); } else { lockdep_unpin_lock(&rq->lock); raw_spin_unlock_irq(&rq->lock); } balance_callback(rq);}

_schedule执行过程主要分为以下几个阶段:

1)关闭内核抢占,初始化一部分变量。获得当前CPU的ID号,并赋值给局部变量CPU。使rq指向CPU对应的运行队列(runqueue)。标识当前CPU发生任务切换,通知RCU更新状态,如果当前CPU处于rcu_read_lock状态,当前进程将会放入rnp->blkd_tasks阻塞队列,并呈现在rnp->gp_tasks链表中。(关于RCU机制,在第2章中介绍)。关闭本地中断,获取所要保护的运行队列(runqueue)的自旋锁(spinlock),为查找可运行进程做准备。

2)检查prev的状态。如果不是可运行状态,而且没有在内核态被抢占,就应该从运行队列中删除prev进程。但是,如果它是非阻塞挂起信号,而且状态为TASK_INTER-RUPTIBLE,函数就把该进程的状态设置为TASK_RUNNING,并将它插入到运行队列。

3)task_on_rq_queued(prev)将pre进程插入到运行队列的队尾。

4)pick_next_task选取下一个将要执行的进程。

5)context_switch(rq,prev,next)进行进程上下文切换。

通过上述步骤可以发现,调度无非就是找一个已有的进程,然后进行上下文切换,并让它执行而已。

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载