第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说明了常见的线程池实现方案,线程池可以在初始化的时候批量创建线程,然后用户后续通过队列等方式提交业务逻辑,线程池中的线程进行逻辑的消费工作,这样就可以在操作的过程中降低线程创建和销毁的开销,但是调度的开销还是存在的。
图1-3 线程池实现原理
在多核场景下,如果是I/O密集型场景,就算开多个线程来处理,也未必能提升CPU的利用率,反而会增加线程切换的开销。另外,多线程之间假如存在临界区或者共享数据,那么同步的开销也是不可忽视的。协程恰恰就是用来解决该问题的。协程是轻量级线程,在一个用户线程上可以跑多个协程,这样就可以提升单核的利用率。在实际场景下,假如CPU有N个核,就只要开N+1个线程,然后在这些线程上面跑协程就行了。但是,协程不像进程或者线程,可以让系统负责相关的调度工作,协程是处于一个线程当中的,系统是无感知的,所以需要在该线程中阻塞某个协程的话,就需要手工进行调度。假如需要设计一套通用的解决方案,那么就需要一番精心的设计。图1-4是一种简单的用户线程上的协程解决方案。
图1-4 协程的实现方案
要在用户线程上实现协程是一件很难受的事情,原理类似于调度器根据条件的改变不停地调用各个协程的callback机制,但是前提是大家都在一个用户线程下。要注意,一旦有一个协程阻塞,其他协程也都不能运行了。因此要处理好协程。
下面我们来看一段PHP代码,通过生产者-消费者程序来模拟实现协程的例子:
import time def 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.2 Linux对进程和线程的实现
通过上一节的介绍,大家应该大致了解了进程和线程在操作系统中的概念和玩法,那么对应到具体的Linux系统中,是否就如上面描述的那样呢?下面来分析Linux中对进程和线程的实现。为了便于理解,首先通过图1-5来简单介绍Linux进程相关的知识结构。
图1-5 Linux进程相关的知识结构
从图中可以发现,进程和线程(包括内核线程)的创建,都是通过系统调用来触发的,而它们最终都会调用do_fork函数,系统调用通过libc这样的库函数封装后提供给应用层调用,进程创建后会产生一个task_struct结构,schedule函数会通过时钟中断来触发调度。后面会进行具体的分析。
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所示。代码如下:
图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.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_all ENDPROC(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 <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> 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.c strace ./test_pthread.c mmap(NULL, 8392704, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_ STACK, -1, 0) = 0x7fb6ade8a000 brk(0) = 0x93d000 brk(0x95e000) = 0x95e000 mprotect(0x7fb6ade8a000, 4096, PROT_NONE) = 0 clone(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)进行进程上下文切换。
通过上述步骤可以发现,调度无非就是找一个已有的进程,然后进行上下文切换,并让它执行而已。
注意
挑选next进程的过程相对复杂,分析起来也比较麻烦,限于篇幅和时间有限,暂时不介绍具体挑选的调度算法实现,这里仅介绍Linux调度的架构,图1-10是Linux的调度架构图。
图1-10 调度的架构图
Linux调度架构的核心概念如下:
1)rq:可运行的队列,每个CPU对应一个,包含自旋锁、进程数量、用于公平调度的CFS信息结构、当前正在运行的进程描述符等。实际的进程队列用红黑树来维护(通过CFS信息结构来访问)。
2)cfs_rq:cfs调度的运行队列信息,包含红黑树的根结点、正在运行的进程指针、用于负载均衡的叶子队列等。
3)sched_entity:把需要调度的东西抽象成调度实体,调度实体可以是进程、进程组、用户等。这里包含负载权重值、对应红黑树结点、虚拟运行时vruntime等。
4)sched_class:把调度策略(算法)抽象成调度类,包含一组通用的调度操作接口,将接口和实现分离。你可以根据这组接口实现不同的调度算法,使得一个Linux调度程序可以有多个不同的调度策略。
1.3.2 进程切换的原理
在挑选完next进程之后,就开始准切换到next进程。
可以将进程理解为正在利用CPU工作的任务。因为在系统中同时运行的进程有很多,CPU不能仅仅被同一个进程使用,所以,这时候就需要进程切换机制,另外,假如某进程的工作大部分为I/O操作,占用CPU空跑会导致资源浪费,这样的进程需要主动放弃CPU。
需要进程切换的场景有以下几种:
该进程分配的CPU时间片用完。
该进程主动放弃CPU(例如IO操作)。
某一进程抢占CPU获得执行机会。
Linux并没有使用x86 CPU自带的任务切换机制,而是通过手工的方式实现了切换,切换过程通过以下switch_to宏来定义:
#define switch_to(prev, next, last) do { unsigned long ebx, ecx, edx, esi, edi; asm volatile("pushfl\n\t" // 步骤1 "pushl %%ebp\n\t" // 步骤2 "movl %%esp, %[prev_sp]\n\t" // 步骤3 "movl %[next_sp], %%esp\n\t" // 步骤4 "movl $1f, %[prev_ip]\n\t" // 步骤5 "pushl %[next_ip]\n\t" // 步骤6 __switch_canary "jmp __switch_to\n" // 步骤7 "1:\t" "popl %%ebp\n\t" // 从栈恢复EBP "popfl\n" // 从栈恢复flags // asm内嵌汇编的输出参数 [prev_sp] "=m" (prev->thread.sp), [prev_ip] "=m" (prev->thread.ip), "=a" (last), "=b" (ebx), "=c" (ecx), "=d" (edx), "=S" (esi), "=D" (edi) __switch_canary_oparam // asm内嵌汇编的输入参数 [next_sp] "m" (next->thread.sp), [next_ip] "m" (next->thread.ip), [prev] "a" (prev), [next] "d" (next) __switch_canary_iparam "memory"); } while (0)
该切换过程分为以下几个步骤:
1)pushfl保存eflags寄存器中的数据到进程本身的堆栈。
2)保存堆栈指针ebp寄存器地址。
3)把堆栈寄存器esp的地址保存到prev->thread.sp中。
4)把next->thread.sp的地址送入到sp寄存器中,这个时候其实已经跑在新的next进程的上下文中了。
5)把当前的eip地址保存到prev->thread.ip中。
6)pushfl把next->thread.ip的地址压入到当前堆栈中。
7)通过jmp__switch_to指令,不管__switch_to做了什么,ret返回地址之前已经被设置成了next->thread.ip的地址,所以将会执行之前在copy_thread_tls中设置的ret_from_fork。
通过这个过程,可以了解到在Linux中,我们并没有对TSS进行特殊处理,而是每个CPU持有唯一一份TSS,它的作用也仅仅是在权限级做跃迁的时候保存堆栈上下文,可以通过图1-11理解进程切换机制。
图1-11 进程切换原理图
注意
关于x86架构CPU的任务切换机制,可以参考阅读《Intel开发手册》,可以从Intel官网下载。另外,本人也编写了代码来模拟两个进程切换的过程,供大家参考,便于加深理解:https://github.com/lingqi1818/analysis_linux/tree/master/ch01/test03关于asm内嵌汇编语法可以参考:https://www.ibm.com/developerworks/cn/linux/sdk/assemble/inline/index.html
1.3.3 调度中的CPU亲和度
我们已经知道,进程创建出来后在内核中的数据结构为task_struct,该结构中有掩码属性cpus_allowed,这个掩码由n位组成,与CPU中的每个逻辑核心一一对应。具有4个核的CPU可以有4位。假如CPU启用了超线程,那么刚才这个CPU就有一个8位的掩码,进程可以运行在掩码位设置为1的CPU上。
Linux内核API提供了两个系统调用,让用户可以修改位掩码或查看当前的位掩码:
sched_setaffinity():用来修改位掩码。
sched_getaffinity():用来查看当前的位掩码。
这两个调用实现的仅仅就是修改或者获取cpus_allowed的值。
在下次task被唤醒的时候,select_task_rq_fair根据cpu_allowed里的掩码来确定将其置于哪个CPU的运行队列,一个进程在某一时刻只能存在于一个CPU的运行队列里。
在Nginx中,就使用了CPU亲和度来完成某些场景的工作:
worker_processes 4; worker_cpu_affinity 0001001001001000;
上面这个配置说明了4个工作进程中的每一个和一个CPU核挂钩。
worker_processes 2; worker_cpu_affinity 01011010;
上面这个配置则说明了两个工作进程中的每一个和2个核挂钩。
看Nginx的实现,核心函数为ngx_setaffinity:
void ngx_setaffinity(uint64_t cpu_affinity, ngx_log_t *log) { cpu_set_t mask; ngx_uint_t i; … CPU_ZERO(&mask); i = 0; do { if (cpu_affinity & 1) { CPU_SET(i, &mask); } i++; cpu_affinity >>= 1; } while (cpu_affinity); if (sched_setaffinity(0, sizeof(cpu_set_t), &mask) == -1) { ngx_log_error(NGX_LOG_ALERT, log, ngx_errno, "sched_setaffinity() failed"); } }
这里主要的操作就是sched_setaffinity。
再结合Nginx文档中的例子和Nginx的源码来看:
worker_processes 4; worker_cpu_affinity 0001001001001000;
如果这个内容写入Nginx的配置文件中,然后Nginx启动或者重新加载配置的时候,若worker_process是4,就会启用4个worker,然后把worker_cpu_affinity后面的4个值当作4个cpu affinity mask,分别调用ngx_setaffinity,然后就把4个worker进程分别绑定到CPU0~3上。
1.4 在应用程序中管理进程和线程
在了解了Linux对进程和线程的实现之后,我们首要的目的还是要学习如何在实际应用程序开发中使用这些技术,不同的应用程序实现了不同的进程或线程的管理模型,而每一种模型的背后,都体现了作者对业务的理解和场景化的考虑。下面我们介绍两种不同软件的管理模型。
1.4.1 Memcached线程池模型分析
Memcached是一款服务器内存管理软件,它主要是由pthread创建的用户工作线程池模型来处理主要逻辑的,图1-12是Memcached的线程模型图。
图1-12 Memcached线程模型图
其主要概念如下:
mthread主线程,主要用于监听socket事件,并建立连接,然后把连接和相应的事件分发到cq连接队列中(每个分线程都拥有一个连接队列)。
cthread分线程,用于从连接队列中获取连接的读写事件,并进行业务逻辑的处理工作。
从Memcached的线程池初始化逻辑中我们可以发现,cthread是个线程池,用户可以指定池子的大小:
void thread_init(int nthreads, struct event_base *main_base) { int i; int power; pthread_mutex_init(&cache_lock, NULL); pthread_mutex_init(&stats_lock, NULL); pthread_mutex_init(&init_lock, NULL); pthread_cond_init(&init_cond, NULL); pthread_mutex_init(&cqi_freelist_lock, NULL); cqi_freelist = NULL; … dispatcher_thread.base = main_base; dispatcher_thread.thread_id = pthread_self(); … // 在设置完libevent后,创建线程 for (i = 0; i < nthreads; i++) { create_worker(worker_libevent, &threads[i]); } // 等待,直到所有线程设置完毕并返回 pthread_mutex_lock(&init_lock); wait_for_thread_registration(nthreads); pthread_mutex_unlock(&init_lock); }
Memcached在创建工作线程的时候,同样会用pipe调用创建管道,用于和主线程之间的通信。
create_worker函数最终通过pthread_create来创建工作线程:
static void create_worker(void *(*func)(void *), void *arg) { pthread_t thread; pthread_attr_t attr; int ret; pthread_attr_init(&attr); if ((ret = pthread_create(&thread, &attr, func, arg)) ! = 0) { fprintf(stderr, "Can't create thread: %s\n", strerror(ret)); exit(1); } }
该模型假设在业务逻辑繁忙,并且I/O开销比较大的情况下,多线程模型能提高系统的吞吐率。但缺点是当多线程同时访问同一数据的时候就存在竞争,需要额外的并发解决开销(比如锁)。另外其实Memcached大部分操作都是基于内存的读写,应该速度很快,引入并发反而在竞争中存在效率降低的风险,另外假如系统中线程数量开得太多,那么线程切换的开销也会上升,需要根据实际场景谨慎设置线程池的大小。而Redis的作者认为内存的操作速度是很快的,所以实现了单线程的服务器模型,在下一章介绍并发的时候再详细介绍。
1.4.2 Nginx进程模型分析
刚才介绍的Memcached是比较经典的服务器线程池模型,比如老牌静态服务器软件Apache就是采用这样的模型,而Nginx的作者则对该模型进行了改进。
Nginx只要创建CPU核心数量相等的工作进程,即可满足高并发、高吞吐量的需求,原因是它的每个工作进程都持有一个基于I/O多路复用的epoll池子(见图1-13),这样每个进程只有在事件被触发的场景下才进行工作,否则就会让出CPU进行其他事件的处理,特别是在upstream的场景下,工作进程可以悠闲地等待后端数据准备好之后再进行工作,CPU的利用率也大大提升。
图1-13 Nginx工作进程模型
在Nginx中master进程通过fork调用派生完子进程后,又通过socketpair创建了管道来进行父子进程之间的通信。
通过了解Memcached和Nginx的线程池和工作进程模型,我们发现有多种选择,既多线程与单线程,线程池模型与工作进程模型,选择哪种模型好?答案不是绝对的,需要根据业务场景具体分析后,找到问题的症结在哪里,才能给出具体的答案。
1.5 处理进程和线程的相关工具
在了解了Linux进程和线程的实现后,在具体的开发和运维场景下如何驾驭它们呢?下面我们简单介绍几个工具,在Linux下可用于调试、追踪系统调用并进行性能分析。
1.5.1 开发环境调试线程
当使用gdb调试C程序的时候,比如Nginx、Nginx的子进程都是fork出来的,所以当开发完并定义模块设置断点调试的时候,默认是无法进入断点的,gdb提供了调试线程的方法。
跟踪子进程:
(gdb)set follow-fork-mode child
跟踪父进程:
(gdb)set follow-fork-mode parent
设置gdb在fork时询问跟踪哪一个进程:
(gdb)set follow-fork-mode ask
根据以上方法进行设置之后,我们就可以在相应的线程实现处设置断点并进行跟踪了。
1.5.2 进程崩溃调试方法
在C程序崩溃的时候往往会留下coredump文件,供我们分析问题到底出在哪里。下面我们用一个Nginx崩溃的场景来分析如何调试coredump文件。
曾经遇到个问题,因为后端领取奖品的接口存在并发操作,有可能会出现超领的情况,但是在分析请求日志的过程中发现,在Tomcat中,出现了2条领取记录,并且已经成功,但是,在Nginx中却只有一条记录。感觉很奇怪,困扰了很久。
不过在分析Nginx的error.log的时候,发现了一些蛛丝马迹:
[alert] 92648#0: worker process 22459 exited on signal 11
原来在被多领取的时候,还发生过Nginx的woker进程退出的情况。signal 11也就是SIGSEGV信号,说明有非法内存访问的情况。
那么,为什么会有这样的问题呢?难道编写的Nginx模块中有潜在的bug?于是在Nginx配置中,设置打开coredump的功能:
worker_rlimit_core 500m; working_directory /tmp;
然后,用gdb来调试产生的coredump文件:
gdb /usr/sbin/nginx core.23161
发现问题出在get_root_domain函数,但是由于Nginx没有debug信息,无法获取具体文件和行号,查看Nginx官方文档,编译的时候产生debug调试信息可以如下操作:“编译器需要使用正确的参数。假如你使用的是GCC, -g参数,会在代码编译后加入调试信息,另外,你需要禁用编译器优化,通过使用-O0参数,可以让调试器输出容易看懂的信息。”
我们可以重新编译Nginx:
CFLAGS="-g -O0" ./configure ....
然后,重新执行:
gdb /usr/sbin/nginx core.23161
显示如下:
sysop@api-1:~$ gdb /usr/sbin/nginx core.23176 GNU gdb (GDB) 7.4.1-debian Copyright (C) 2012 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>... Reading symbols from /usr/sbin/nginx...Reading symbols from /usr/lib/debug/ usr/sbin/nginx...done. done. [New LWP 23176] Core was generated by `nginx: worker pr'. Program terminated with signal 11, Segmentation fault. #0 0x000000000050267c in get_root_domain (domain=<error reading variable: Cannot access memory at address 0x8>) at src/http/modules/ngx_http_beacon_ module.c:298
get_root_domain代码如下:
size_t get_root_domain(u_char **p, ngx_str_t *domain){ *p = domain->data; int i = domain->len -1; ngx_flag_t is_first = 0; ...
我们发现domain指针指向的是0x8这个地址,这么低的地址理论上应该是系统保护的地址,不能被程序访问,那么domain是如何来的呢?
domain_len = get_root_domain(&domain, &r->headers_in.host->value);
返现直接取的是HTTP协议头中的host信息。
然后再看了一下当时的错误信息,有一些获取验证码的请求是用httpclient构建的,不是通过浏览器发起的请求,那么host信息必然是空的。所以导致这个空指针的异常。
而在当时Nginx的进程退出,正好影响了正常的请求,导致返回的时候没打印日志以及吐数据给客户就挂了。
最后程序对这种情况做了兼容,修复了这个诡异的问题。
1.5.3 strace工具
strace是Linux提供的一个工具,常用来跟踪进程执行时的系统调用和所接收的信号。比如:
[root@lingqi1818~]# strace cat /dev/null execve("/bin/cat", ["cat", "/dev/null"], [/* 27 vars */]) = 0 brk(0) = 0x250d000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1e0c0bf000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) open("/etc/ld.so.cache", O_RDONLY) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=26432, ...}) = 0 mmap(NULL, 26432, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f1e0c0b8000 close(3) = 0 open("/lib64/libc.so.6", O_RDONLY) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0000\356\1\0\0\0\0\0"... , 832) = 832 fstat(3, {st_mode=S_IFREG|0755, st_size=1921216, ...}) = 0 mmap(NULL, 3750152, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f1e0bb0d000 mprotect(0x7f1e0bc98000, 2093056, PROT_NONE) = 0 mmap(0x7f1e0be97000, 20480, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ DENYWRITE, 3, 0x18a000) = 0x7f1e0be97000 mmap(0x7f1e0be9c000, 18696, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ ANONYMOUS, -1, 0) = 0x7f1e0be9c000 close(3) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1e0c0b7000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1e0c0b6000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1e0c0b5000 arch_prctl(ARCH_SET_FS, 0x7f1e0c0b6700) = 0 mprotect(0x7f1e0be97000, 16384, PROT_READ) = 0 mprotect(0x7f1e0c0c0000, 4096, PROT_READ) = 0 munmap(0x7f1e0c0b8000, 26432) = 0 brk(0) = 0x250d000 brk(0x252e000) = 0x252e000 open("/usr/lib/locale/locale-archive", O_RDONLY) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=99158720, ...}) = 0 mmap(NULL, 99158720, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f1e05c7c000 close(3) = 0 fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0 open("/dev/null", O_RDONLY) = 3 fstat(3, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 3), ...}) = 0 read(3, "", 32768) = 0 close(3) = 0 close(1) = 0 close(2) = 0 exit_group(0) = ?
以上代码每一行都是一个系统调用,等号左边是系统调用的函数名及其参数,右边是该调用的返回值。
strace的具体参数含义可以通过man指令来查询,比如我们常用的-c参数可以统计每一次系统调用所执行的时间、次数和出错的次数等:
[root@lingqi1818~]# strace -c cat /dev/null % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 0.00 0.000000 0 2 read 0.00 0.000000 0 4 open 0.00 0.000000 0 6 close 0.00 0.000000 0 5 fstat 0.00 0.000000 0 9 mmap 0.00 0.000000 0 3 mprotect 0.00 0.000000 0 1 munmap 0.00 0.000000 0 3 brk 0.00 0.000000 0 1 1 access 0.00 0.000000 0 1 execve 0.00 0.000000 0 1 arch_prctl ------ ----------- ----------- --------- --------- ---------------- 100.00 0.000000 36 1 total
1.5.4 SystemTap工具
SystemTap是基于kprobe的实现(关于kprobe网上资料较多,大家可以自行研究),其功能非常强大,可以监控内核和用户程序。
下面以实际场景为例,来监控运行中程序指定函数的调用参数值。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> void print(char *p){ printf("%s.\n", p); } void* test_fn(void* arg) { while(1){ print("hello pthread"); 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; }
以上程序每5秒钟会调用一次print,我要是想知道print的输入参数是什么,那么编写SystemTap脚本如下:
function myprint:string (val) %{ char *str = (char *)STAP_ARG_val; snprintf(STAP_RETVALUE, MAXSTRINGLEN, "%s", str); %} probe process(3266).function("print") {printf("%s\n", myprint($p)); }
运行结果为:
stap -g test.stp
这样就可以获得监控的结果了,因为我用到了内嵌C来获取字符串的值,所以就需要加上-g参数。
1.5.5 DTrace工具
DTrace是Oracle旗下的一款基于Linux的监控程序,它可以基于D语言编写脚本来实现你想要的监控功能,由于功能比较复杂,这里不做过多阐述,大家可以有兴趣到下面的网址了解更多信息:
http://www.oracle.com/technetwork/cn/articles/servers-storage-admin/dtrace-on-linux-1956556-zhs.html
http://docs.oracle.com/cd/E24847_01/html/E22192/toc.html
下面的脚本用于监控指定pid下指定的系统调用是否发生:
test.d: pid$1::$2:entry { self->trace = 1; } pid$1::$2:return /self->trace/ { self->trace = 0; } pid$1:::entry, pid$1:::return /self->trace/ { }
然后我们打开Redis进程:
localhost:src chenke$ ./redis-server
其pid为:
chenke 7276 0.0 0.024650801892 s003 S+ 12:32下午 0:00.01 ./redis-server *:6379
现在进行写入操作:
localhost:~ chenke$ telnet localhost 6379 Trying ::1... Connected to localhost. Escape character is '^]'. set a 1 +OK
我们来看监控脚本产生的数据如下:
dtrace: script 'test.d' matched 33829 probes CPU ID FUNCTION:NAME 0 257241 write:entry 0 257241 write:entry 0 257241 write:entry 0 257241 write:entry 0 257241 write:entry 0 257241 write:entry
当然,更复杂的功能需要自己研究手册和D语言,而且DTrace的好处是可以监控用户态的程序。
1.6 本章小结
进程和线程是计算机发展历史上为解决特定问题而产生的解决方案。本章开头介绍了进程管理的历史,以及实现原理,特别是内核线程、用户线程和协程,只有了解了这些原理,才能更好地编写应用程序。
随后介绍了Linux对进程和线程的实现,还介绍了内核对进程和线程的调度机制,调度机制的好坏决定了一个操作系统是否能流畅响应不同用户的实时请求。
我们平时使用了很多开源的软件,进程和线程的模型是服务器实现必须要考虑的问题,在了解了原理以及Linux的实现后,再对Memcached和Nginx相关模型进行分析,有助于更好地理解进程和线程。
最后,通过对gdb、coredump、strace、SystemTap、DTrace等工具的介绍,有助于我们进行开发调试、故障诊断、监控分析等,建议大家可以多动手实践。