linux内核分析和应用 -- 进程与线程(上)

只要是计算机科班出身的技术人员,肯定都学过现代操作系统课程。一般在操作系统的书中都会有这样的定义:

简单来说,进程就是在操作系统中运行的程序,是操作系统资源管理的最小单位。一个进程可以管理多个线程,线程相对轻量,可以共享进程地址空间。

我在很多次面试的时候,向求职者提问过进程和线程在 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 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 进程相关的知识结构。

从图中可以发现,进程和线程(包括内核线程)的创建,都是通过系统调用来触发的,而它们最终都会调用 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_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 结构。

猜你喜欢

转载自blog.csdn.net/armlinuxww/article/details/87919985