深入linux内核架构--进程&线程

Posted Linux加油站

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入linux内核架构--进程&线程相关的知识,希望对你有一定的参考价值。

简介

进程和线程这两个词,每个程序员都十分熟悉,但是想要很清晰的描述出来却有一种不知道从何说起的感觉。所以今天结合一个具体的例子来描述一下进程与线程的相关概念:在terminal上敲出a.out这个自己编译出来可执行程序路径后,这个程序是怎么在linux系统中运行起来的?如果对于这个问题有兴趣的同学可以继续往下看。

对于linux内核下进程和线程的理解程度我经历过以下几个阶段:

  1. 知道进程和线程这两个基本概念:知道进程/线程相关的一些资源占用,调度的一些基本理论知识;知道进程=程序+数据等等;知道PC寄存器、指令、栈帧、上下文切换等一些零碎的概念。
  2. 知道进程和线程都是可独立调度的最小单位; 知道进程拥有独立地址空间,线程共享进程的地址空间,有自己独立的栈;知道pthread协议相关接口定义,以及会使用pthread接口创建线程/进程等操作。
  3. 知道线程在linux中本质上就是进程,可以分为内核线程,可调度线程和用户空间线程。内核线程是内核启动后的一些deamon线程用于处理一写内核状态工作,比如kswapd,flushd等;可调度线程是能够被内核调度感知的线程,也就是正常的native thread;用户空间线程不为内核调度感知,比如goroutine,gevent等,需要自己实现相关调度策略。
  4. 知道进程和线程在内核中是如何调度的:实时调度器,完全公平调度器等相关调度策略;知道可调度线程有内核栈和用户态栈两个不同的栈,当执行系统调用时需要从用户态栈切换到内核栈执行一些特权指令,比如write,mmap之类的。
  5. 知道在terminal bash中输入一个cmd之后,相关的进程是如何被操作系统运行起来的,知道线程是如何创建出来的。
  6. 知道栈中的指令是如何加载到CPU中,如何开始执行,上下文切换时的寄存器如何操作等。(这个阶段还没达到,不过不做内核开发的话,也没必要达到这个阶段。)

那么我们就通过结合linux源码以及一些man文档一起来深入了解一下进程和线程的实现,结合简介中说的例子来看一下linux用户进程及线程是如何被创建起来以及执行的。

【文章福利】小编推荐自己的Linux内核技术交流群:【977878001】整理一些个人觉得比较好得学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!前100进群领取,额外赠送一份价值699的内核资料包(含视频教程、电子书、实战项目及代码)

内核资料直通车:Linux内核源码技术学习路线+视频教程代码资料

学习直通车:Linux内核源码/内存调优/文件系统/进程管理/设备驱动/网络协议栈

进程 & 线程

task_struct,mm_struct这两个数据结构,分别描述进程/线程的数据结构及进程虚拟地址空间,是内核管理的关键结构,下面是相关内核代码。

struct task_struct 
#ifdef CONFIG_THREAD_INFO_IN_TASK
    /*
     * For reasons of header soup (see current_thread_info()), this
     * must be the first element of task_struct.
     */
    struct thread_info      thread_info;
#endif
    /* -1 unrunnable, 0 runnable, >0 stopped: */
    volatile long           state;  // 运行状态

    void                *stack;  // 内核栈空间地址
    atomic_t            usage;
    /* Per task flags (PF_*), defined further below: */
    unsigned int            flags;
    unsigned int            ptrace; // 

#ifdef CONFIG_SMP  // 调度相关
    struct llist_node       wake_entry;
    int             on_cpu; // 是否已被分配到CPU上执行调度
#ifdef CONFIG_THREAD_INFO_IN_TASK
    /* Current CPU: */
    unsigned int            cpu; // 被分配到的CPU
...
#endif
// 调度相关
    int             on_rq; // 是否在runqueue队列中(就绪)
    int             prio; // 优先级
    int             static_prio; // 静态优先级
    int             normal_prio; // 普通优先级
    unsigned int            rt_priority; // 实时优先级
        // 上述优先级结合一个简单的算法来计算task_struct的权重,用于计算时间片 
    const struct sched_class    *sched_class; // 调度类,诸如实时调度,完全公平调度等
    struct sched_entity     se; // 关联到的调度实体,这是完全公平调度器真正调度的单位
    struct sched_rt_entity      rt; // 关联到的实时调度实体,这是实时调度器真正调度的单位
#ifdef CONFIG_CGROUP_SCHED
    struct task_group       *sched_task_group; // 组调度,cgroup相关
#endif
    struct sched_dl_entity      dl; // deadline调度器调度实体
...
    struct mm_struct        *mm; // 虚拟内存结构
    struct mm_struct        *active_mm;
...
    pid_t               pid;  // 进程id
    pid_t               tgid; // 组id

    /* Real parent process: */
    struct task_struct __rcu    *real_parent; // 真实的父进程(ptrace的时候需要用到)

    /* Recipient of SIGCHLD, wait4() reports: */
    struct task_struct __rcu    *parent; // 当前父进程

    /*
     * Children/sibling form the list of natural children:
     */
    struct list_head        children; // 子进程(当前进程fork出来的子进程/线程)
    struct list_head        sibling; // 兄弟进程
    struct task_struct      *group_leader;

    /*
     * 'ptraced' is the list of tasks this task is using ptrace() on.
     *
     * This includes both natural children and PTRACE_ATTACH targets.
     * 'ptrace_entry' is this task's link on the p->parent->ptraced list.
     */
    struct list_head        ptraced;
    struct list_head        ptrace_entry;

    /* PID/PID hash table linkage. */ //线程相关结构
    struct pid          *thread_pid;
    struct hlist_node       pid_links[PIDTYPE_MAX];
    struct list_head        thread_group;
    struct list_head        thread_node;

    struct completion       *vfork_done;

    /* CLONE_CHILD_SETTID: */
    int __user          *set_child_tid;

    /* CLONE_CHILD_CLEARTID: */
    int __user          *clear_child_tid;

    u64             utime; // 用户态运行时间
    u64             stime; // 和心态运行时间
...
    /* Context switch counts: */
    unsigned long           nvcsw; //自愿切换
    unsigned long           nivcsw; //非自愿切换

    /* Monotonic time in nsecs: */
    u64             start_time; // 创建时间

    /* Boot based time in nsecs: */
    u64             real_start_time; // 创建时间
    char                comm[TASK_COMM_LEN]; // 执行cmd名称,包括路径
    struct nameidata        *nameidata;  // 路径查找辅助结构
...
    /* Filesystem information: */
    struct fs_struct        *fs; // 文件系统信息,根路径及当前文件路径

    /* Open file information: */
    struct files_struct     *files; // 打开的文件句柄

    /* Namespaces: */
    struct nsproxy          *nsproxy; 

    /* Signal handlers: */
    struct signal_struct        *signal; // 信号量
    struct sighand_struct       *sighand; // 信号量处理函数
...
       /* CPU-specific state of this task: */
    struct thread_struct        thread; // 线程相关信息
;

struct mm_struct 
    struct 
                ... // 虚拟地址空间相关数据结构(虚拟地址空间比较复杂,这里不做过多介绍)
        unsigned long task_size;    /* size of task vm space: 虚拟地址空间大小 */
        unsigned long highest_vm_end;   /* highest vma end address: 堆顶地址 */
        pgd_t * pgd; // 页表相关
        atomic_t mm_users; // 共享该空间的用户数
        atomic_t mm_count; // 共享该空间的线程数(linux中线程与进程共享mm_struct)
#ifdef CONFIG_MMU
        atomic_long_t pgtables_bytes;   /* PTE page table pages:页表项占用的内存页数 */
#endif
        int map_count;          /* number of VMAs */

        spinlock_t page_table_lock; /* Protects page tables and some
                         * counters
                         */
        struct rw_semaphore mmap_sem;
        struct list_head mmlist; /* List of maybe swapped mm's. These
                      * are globally strung together off
                      * init_mm.mmlist, and are protected
                      * by mmlist_lock
                      */
        ... // 统计信息

        unsigned long stack_vm;    /* VM_STACK */
        unsigned long def_flags;

        spinlock_t arg_lock; /* protect the below fields */
        unsigned long start_code, end_code, start_data, end_data; // 代码段,数据段
        unsigned long start_brk, brk, start_stack; // 堆起始地址,堆顶地址,进程栈地址
        unsigned long arg_start, arg_end, env_start, env_end; // 初始变量段起始地址,环境变量段起始地址
...
        struct linux_binfmt *binfmt; // 进程启动相关信息
...
;

进程是从内核最原始的调度概念,伴随着内核诞生的,是调度的主体,每个进程都有唯一的进程ID用来标识,且在每一个命名空间都有一个独立的进程ID,进程ID在IPC中有着关键的作用。进程伴随着虚拟内存空间,内核栈,用户态栈,进程组,命名空间,打开文件,信号等资源。每个进程都有一些调度相关数据:优先级,cgroup;这两个数据用于计算进程运行过程中的时间片大小。进程可以被不同的调度器调度,主要的调度器包括:完全公平调度器,实时调度器,deadline调度器等。每个进程主要有三种状态:阻塞、就绪、运行中。
但是当你输入ps aux可以看到很多状态标识,分别表示:

PROCESS STATE CODES
       Here are the different values that the s, stat and state output
       specifiers (header "STAT" or "S") will display to describe the state of
       a process:
               D    uninterruptible sleep (usually IO) // 不可中断阻塞,IO访问涉及到设备操作,如果要实现可中断操作比较复杂,一般是进入D状态,禁止竞争。(无法接受信号,不能被kil)
               R    running or runnable (on run queue)
               S    interruptible sleep (waiting for an event to complete)// 可中断阻塞(能够接受信号,可以被kill)
               T    stopped by job control signal
               t    stopped by debugger during the tracing
               W    paging (not valid since the 2.6.xx kernel)
               X    dead (should never be seen)
               Z    defunct ("zombie") process, terminated but not reaped by
                    its parent

       For BSD formats and when the stat keyword is used, additional
       characters may be displayed:

               <    high-priority (not nice to other users)
               N    low-priority (nice to other users)
               L    has pages locked into memory (for real-time and custom IO)
               s    is a session leader
               l    is multi-threaded (using CLONE_THREAD, like NPTL pthreads
                    do)
               +    is in the foreground process group

而线程是一个通用的标准概念,并不是操作系统的概念,很多操作系统最开始都没有线程的设计。线程的概念起源于pthread,其定义了一套POSIX标准接口,相比于原始进程,线程定义了一套具有更轻量级的资源分配,更方便的通信以及更融洽的协作等优势的调度标准,这个接口提供了一系列线程操作接口及线程通信接口。linux参考于unix,设计之初并没有考虑到这种设计,也就没有单独的特殊数据结构来描述线程,而且可以被调度的唯一结构也只有task_struct这一种结构。最开始实现pthread的实现库被称为LinuxThreads,但是该库对于pthread的支持并不是很兼容,因为内核很多设计没法很好的实现线程相关标准要求。内核为了更好的支持pthread,修改了task_struct中相关的数据结构及管理方式来支持更好的实现pthread标准,也就有了后来NPTL库,该库完美的兼容了POSIX协议中的各项要求,因此linux下线程又被成为LWP(Light-weight process)。

LinuxThreads

实现原理:
通过一个管理进程来创建线程,所有的线程操作包括创建、销毁等都由这个进程来执行。所以的线程本质上基本都是一个独立的进程,因此对POSIX要求的很多标准不兼容,具有很大的局限性。
LinuxThreads的局限性:

  1. 只有一个管理进程来协调所有线程,线程的创建和销毁开销大(管理进程是瓶颈)。
  2. 管理进程本身也需要上下文切换,且只能运行在一个CPU上,伸缩性和性能差。
  3. 与POSIX不兼容,诸如每个线程都有单独的进程ID;信号通信没法做到进程间级别;用户和组ID信息对进程下的所有线程来说不是通用的。
  4. 进程数有上限,这种线程机制很容易突破这个限制。
  5. ps会显示所有线程。

NPTL

为了更好的兼容POSIX,基于linux内核在2.6之后开发了新的线程库Native POSIX Thread Library,解决了 LinuxThreads中的诸多局限,也完全兼容了POSIX协议,性能和稳定性都有了重大改进。
内核2.4之后对于task_struct的创建提供了更丰富的的参数选择,这样为上层library的实现提供了很多便利,也为去除管理进程提供了可能。上层可以通过参数化的进程创建并结合一些机制来区分实现线程和进程。有了线程组概念后能更好的处理线程之间的关联与信号量处理,这样基本就能去除LinuxThread中的相关限制了。
实现原理:
linux暴露出来创建进程的系统调用主要有fork和clone两个。看一下fork和clone的函数模型:
pid_t fork(void);
int clone(int (*fn)(void *), void *stack, int flags, void arg, ...
/ pid_t *parent_tid, void *tls, pid_t *child_tid */ );
fork只能用来创建进程,因为其没啥可做差异化的参数,而clone中的flags提供了丰富的参数化配置,也是用来做进程/线程差异化的关键,为上层lib更好的实现Thread Library提供了可能,clone系统调用就成为了创建线程的最佳接口。

  1. 通过一些特殊的FLAG可以在创建新的task_struct的时候与父task_struct共享内存空间,共享打开的文件,共享io调度器,共享命名空间,共享cgroup。
  2. 通过线程组的数据结构来标识同一进程下的所有线程,这样就可以实现进程级别的信号IPC通信。
  3. 通过pid及tgid两个参数来区分进程和线程。

我们看一下clone的man文档。clone通过flag来设置需要共享的资源,包括打开的文件,内存地址空间,命名空间等;但是有自己独立的执行栈,也就是方法中的fn地址,可以通过参数化CLONE_FS,CLONE_IO,CLONE_NEWIPC之类的来与父进程隔离独立的资源(这也是container namespace隔离的基础)。
NPTL 基于参数化clone系统调用,可以很好的实现兼容POSIX协议的线程库。而且2.6之后内核还实现了FUTEX系统调用来更好的处理用户空间线程间的通信。

在clone中可以看到需要自己传入执行方法fn的地址,除此之外还需要传入栈地址stack,因此通过clone系统调用创建的线程的栈空间,需要用户自己分配,但不需要自己回收,父进程会负责子进程的资源回收,栈的分配在glibc中的具体实现如下:

static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
        ALLOCATE_STACK_PARMS)

  /* Get memory for the stack.  */
  if (__glibc_unlikely (attr->flags & ATTR_FLAG_STACKADDR))
    
      uintptr_t adj;
      char *stackaddr = (char *) attr->stackaddr;

      /* Assume the same layout as the _STACK_GROWS_DOWN case, with struct
     pthread at the top of the stack block.  Later we adjust the guard
     location and stack address to match the _STACK_GROWS_UP case.  */
      if (_STACK_GROWS_UP)
        stackaddr += attr->stacksize;

      /* If the user also specified the size of the stack make sure it
     is large enough.  */
      if (attr->stacksize != 0
      && attr->stacksize < (__static_tls_size + MINIMAL_REST_STACK))
        return EINVAL;
      /* The user provided stack memory needs to be cleared.  */
      memset (pd, '\\0', sizeof (struct pthread));
      ... // init pd
   else 
    void *mem;
    const int prot = (PROT_READ | PROT_WRITE 
          | ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));
    ...
    mem = mmap (NULL, size, prot,
              MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
    ...
#if TLS_TCB_AT_TP
    pd = (struct pthread *) ((char *) mem + size - coloring) - 1;
#elif TLS_DTV_AT_TP
    pd = (struct pthread *) ((((uintptr_t) mem + size - coloring
                    - __static_tls_size)
                    & ~__static_tls_align_m1)
                   - TLS_PRE_TCB_SIZE);
  


可以看到,glibc中的实现是通过mmap来申请MAP_STACK(grow down) flag的匿名映射作为线程栈地址。这样一个可被调度的native线程就在linux中被创建出来了。

所以知道linux下线程创建的关键之处还是clone的具体实现。fork和clone的会统一到_do_fork这个内核函数,唯一不同的是fork传入的都是默认参数,而clone可以进行特别详细的配置,看一下_do_fork的具体实现:

/*
 *  Ok, this is the main fork-routine.
 *
 * It copies the process, and if successful kick-starts
 * it and waits for it to finish using the VM if required.
 */
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)

    struct completion vfork; // 创建子进程后回调,vfork不拷贝页表页,
    struct pid *pid;
    struct task_struct *p;
    int trace = 0;
    long nr;

    /*
     * Determine whether and which event to report to ptracer.  When
     * called from kernel_thread or CLONE_UNTRACED is explicitly
     * requested, no event is reported; otherwise, report if the event
     * for the type of forking is enabled.
     */
    if (!(clone_flags & CLONE_UNTRACED))  // ptrace相关
        if (clone_flags & CLONE_VFORK)
            trace = PTRACE_EVENT_VFORK;
        else if ((clone_flags & CSIGNAL) != SIGCHLD)
            trace = PTRACE_EVENT_CLONE;
        else
            trace = PTRACE_EVENT_FORK;

        if (likely(!ptrace_event_enabled(current, trace)))
            trace = 0;
    

    p = copy_process(clone_flags, stack_start, stack_size,
             child_tidptr, NULL, trace, tls, NUMA_NO_NODE); // 拷贝task_struct对象,也就是生成一个新的进程/线程
    add_latent_entropy();

    if (IS_ERR(p))
        return PTR_ERR(p);

    /*
     * Do this prior waking up the new thread - the thread pointer
     * might get invalid after that point, if the thread exits quickly.
     */
    trace_sched_process_fork(current, p);

    pid = get_task_pid(p, PIDTYPE_PID);
    nr = pid_vnr(pid);

    if (clone_flags & CLONE_PARENT_SETTID)
        put_user(nr, parent_tidptr);

    if (clone_flags & CLONE_VFORK) // vfork 相关特殊处理
        p->vfork_done = &vfork;
        init_completion(&vfork);
        get_task_struct(p);
    
    wake_up_new_task(p); // 将该task唤醒:放入就绪队列
    /* forking complete and child started to run, tell ptracer */
    if (unlikely(trace))
        ptrace_event_pid(trace, pid);
    if (clone_flags & CLONE_VFORK) 
        if (!wait_for_vfork_done(p, &vfork))
            ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
    
    put_pid(pid); // 记录各个namespace中pid被使用情况
    return nr;

/*
 * This creates a new process as a copy of the old one,
 * but does not actually start it yet.
 *
 * It copies the registers, and all the appropriate
 * parts of the process environment (as per the clone
 * flags). The actual kick-off is left to the caller.
 */
static __latent_entropy struct task_struct *copy_process(
                    unsigned long clone_flags,
                    unsigned long stack_start,
                    unsigned long stack_size,
                    int __user *child_tidptr,
                    struct pid *pid,
                    int trace,
                    unsigned long tls,
                    int node)

    int retval;
    struct task_struct *p;
    struct multiprocess_signals delayed;

    /*
     * Don't allow sharing the root directory with processes in a different
     * namespace
     */
    if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
        return ERR_PTR(-EINVAL);

    if ((clone_flags & (CLONE_NEWUSER|CLONE_FS)) == (CLONE_NEWUSER|CLONE_FS))
        return ERR_PTR(-EINVAL);

    /*
     * Thread groups must share signals as well, and detached threads
     * can only be started up within the thread group.
     */
    if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
        return ERR_PTR(-EINVAL);

    /*
     * Shared signal handlers imply shared VM. By way of the above,
     * thread groups also imply shared VM. Blocking this case allows
     * for various simplifications in other code.
     */
    if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
        return ERR_PTR(-EINVAL);

    /*
     * Siblings of global init remain as zombies on exit since they are
     * not reaped by their parent (swapper). To solve this and to avoid
     * multi-rooted process trees, prevent global and container-inits
     * from creating siblings.
     */
    if ((clone_flags & CLONE_PARENT) &&
                current->signal->flags & SIGNAL_UNKILLABLE)
        return ERR_PTR(-EINVAL);

    /*
     * If the new process will be in a different pid or user namespace
     * do not allow it to share a thread group with the forking task.
     */
    if (clone_flags & CLONE_THREAD) 
        if ((clone_flags & (CLONE_NEWUSER | CLONE_NEWPID)) ||
            (task_active_pid_ns(current) !=
                current->nsproxy->pid_ns_for_children))
            return ERR_PTR(-EINVAL);
    

    /*
     * Force any signals received before this point to be delivered
     * before the fork happens.  Collect up signals sent to multiple
     * processes that happen during the fork and delay them so that
     * they appear to happen after the fork.
     */
    sigemptyset(&delayed.signal);
    INIT_HLIST_NODE(&delayed.node);

    spin_lock_irq(&current->sighand->siglock);
    if (!(clone_flags & CLONE_THREAD))
        hlist_add_head(&delayed.node, &current->signal->multiprocess);
    recalc_sigpending();
    spin_unlock_irq(&current->sighand->siglock);
    retval = -ERESTARTNOINTR;
    if (signal_pending(current))
        goto fork_out;

    retval = -ENOMEM;
    p = dup_task_struct(current, node); // 复制task_struct数据结构
    if (!p)
        goto fork_out;
    /*
     * This _must_ happen before we call free_task(), i.e. before we jump
     * to any of the bad_fork_* labels. This is to avoid freeing
     * p->set_child_tid which is (ab)used as a kthread's data pointer for
     * kernel threads (PF_KTHREAD).
     */
    p->set_child_tid = (clone_flags & CLONE_CHILD_SETTID) ? child_tidptr : NULL; // 设置ThreadId
    /*
     * Clear TID on mm_release()?
     */
    p->clear_child_tid = (clone_flags & CLONE_CHILD_CLEARTID) ? child_tidptr : NULL; // 线程退出时是否清除ThreadId
    ftrace_graph_init_task(p);
    rt_mutex_init_task(p);
...
    retval = -EAGAIN;
    if (atomic_read(&p->real_cred->user->processes) >=
            task_rlimit(p, RLIMIT_NPROC)) 
        if (p->real_cred->user != INIT_USER &&
            !capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
            goto bad_fork_free;
    
    current->flags &= ~PF_NPROC_EXCEEDED;

    retval = copy_creds(p, clone_flags); // 权限相关
    if (retval < 0)
        goto bad_fork_free;

    /*
     * If multiple threads are within copy_process(), then this check
     * triggers too late. This doesn't hurt, the check is only there
     * to stop root fork bombs.
     */
    retval = -EAGAIN;
    if (nr_threads >= max_threads) // 超出线程上线
        goto bad_fork_cleanup_count;

    delayacct_tsk_init(p);  /* Must remain after dup_task_struct() */
    ...
        // 初始化统计信息
    p->utime = p->stime = p->gtime = 0;
#ifdef CONFIG_ARCH_HAS_SCALED_CPUTIME
    p->utimescaled = p->stimescaled = 0;
#endif
    prev_cputime_init(&p->prev_cputime);
#ifdef CONFIG_VIRT_CPU_ACCOUNTING_GEN
    seqcount_init(&p->vtime.seqcount);
    p->vtime.starttime = 0;
    p->vtime.state = VTIME_INACTIVE;
#endif
#if defined(SPLIT_RSS_COUNTING)
    memset(&p->rss_stat, 0, sizeof(p->rss_stat));
#endif
    p->default_timer_slack_ns = current->timer_slack_ns;
#ifdef CONFIG_PSI
    p->psi_flags = 0;
#endif
...
    /* Perform scheduler related setup. Assign this task to a CPU. */
    retval = sched_fork(clone_flags, p);  // 初始化调度器类,优先级等调度相关信息,并根据numa设置一个更倾向于的CPU,将该task放入当前cpu的runqueue
    if (retval)
        goto bad_fork_cleanup_policy;
    retval = perf_event_init_task(p);
    if (retval)
        goto bad_fork_cleanup_policy;
    retval = audit_alloc(p);
    if (retval)
        goto bad_fork_cleanup_perf;
    /* copy all the process information */
    shm_init_task(p);
    retval = security_task_alloc(p, clone_flags);
    if (retval)
        goto bad_fork_cleanup_audit;
    retval = copy_semundo(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_security;
    retval = copy_files(clone_flags, p); // 拷贝打开文件句柄
    if (retval)
        goto bad_fork_cleanup_semundo;
    retval = copy_fs(clone_flags, p); // 拷贝进程路径信息
    if (retval)
        goto bad_fork_cleanup_files;
    retval = copy_sighand(clone_flags, p); // 拷贝信号处理函数
    if (retval)
        goto bad_fork_cleanup_fs;
    retval = copy_signal(clone_flags, p); // 拷贝信号
    if (retval)
        goto bad_fork_cleanup_sighand;
    retval = copy_mm(clone_flags, p); // 拷贝地址空间
    if (retval)
        goto bad_fork_cleanup_signal;
    retval = copy_namespaces(clone_flags, p); // 拷贝命名空间
    if (retval)
        goto bad_fork_cleanup_mm;
    retval = copy_io(clone_flags, p); // 拷贝IO调度相关信息
    if (retval)
        goto bad_fork_cleanup_namespaces;
    retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls); // 设置线程栈帧及相关寄存器上下文信息
    if (retval)
        goto bad_fork_cleanup_io;
    ...
    stackleak_task_init(p); // 记录栈起始地址,防止栈溢出
    if (pid != &init_struct_pid) 
        pid = alloc_pid(p->nsproxy->pid_ns_for_children);
        if (IS_ERR(pid)) 
            retval = PTR_ERR(pid);
            goto bad_fork_cleanup_thread;
        
    
...
    /* ok, now we should be set up.. */
    p->pid = pid_nr(pid);
    if (clone_flags & CLONE_THREAD)  // 设置线程组及实时退出信号量,线程不能处理退出信号(signal numbers 32 and 33)
        p->exit_signal = -1;
        p->group_leader = current->group_leader;
        p->tgid = current->tgid;
     else 
        if (clone_flags & CLONE_PARENT)
            p->exit_signal = current->group_leader->exit_signal;
        else
            p->exit_signal = (clone_flags & CSIGNAL);
        p->group_leader = p;
        p->tgid = p->pid;
    
    ... // cgroup 相关
    /*
     * From this point on we must avoid any synchronous user-space
     * communication until we take the tasklist-lock. In particular, we do
     * not want user-space to be able to predict the process start-time by
     * stalling fork(2) after we recorded the start_time but before it is
     * visible to the system.
     */
    p->start_time = ktime_get_ns();
    p->real_start_time = ktime_get_boot_ns();
    /*
     * Make it visible to the rest of the system, but dont wake it up yet.
     * Need tasklist lock for parent etc handling!
     */
    write_lock_irq(&tasklist_lock);
    /* CLONE_PARENT re-uses the old parent */
    if (clone_flags & (CLONE_PARENT|CLONE_THREAD))  // 设置父进程
        p->real_parent = current->real_parent;
        p->parent_exec_id = current->parent_exec_id;
     else 
        p->real_parent = current;
        p->parent_exec_id = current->self_exec_id;
    
    klp_copy_process(p);
    spin_lock(&current->sighand->siglock);
    /*
     * Copy seccomp details explicitly here, in case they were changed
     * before holding sighand lock.
     */
    copy_seccomp(p); // Secure Computing
    rseq_fork(p, clone_flags);
    /* Don't start children in a dying pid namespace */
    if (unlikely(!(ns_of_pid(pid)->pid_allocated & PIDNS_ADDING))) 
        retval = -ENOMEM;
        goto bad_fork_cancel_cgroup;
    
    /* Let kill terminate clone/fork in the middle */
    if (fatal_signal_pending(current)) 
        retval = -EINTR;
        goto bad_fork_cancel_cgroup;
    
... // pid分配及 更新统计信息 
    return p;
...

static struct task_struct *dup_task_struct(struct task_struct *orig, int node)

    struct task_struct *tsk;
    unsigned long *stack;
    struct vm_struct *stack_vm_area __maybe_unused;
    int err;
    if (node == NUMA_NO_NODE)
        node = tsk_fork_get_node(orig); // 获取内核栈slab节点
    tsk = alloc_task_struct_node(node); // 分配task_struct  slab内存
    if (!tsk)
        return NULL;
    stack = alloc_thread_stack_node(tsk, node); // 分配内核栈slab内存
    if (!stack)
        goto free_tsk;
    if (memcg_charge_kernel_stack(tsk)) // 记录内核栈cgroup统计
        goto free_stack;
    stack_vm_area = task_stack_vm_area(tsk); // 获取栈虚拟地址(通过vmalloc内核空间虚拟映射)
    err = arch_dup_task_struct(tsk, orig); // 将原始task的数据拷贝到新的task中
    /*
     * arch_dup_task_struct() clobbers the stack-related fields.  Make
     * sure they're properly initialized before using any stack-related
     * functions again.
     */
    tsk->stack = stack; // 修改内核栈地址
#ifdef CONFIG_VMAP_STACK
    tsk->stack_vm_area = stack_vm_area; // 修改内核栈虚拟地址
#endif
#ifdef CONFIG_THREAD_INFO_IN_TASK
    atomic_set(&tsk->stack_refcount, 1);
#endif
    if (err)
        goto free_stack;
#ifdef CONFIG_SECCOMP
    /*
     * We must handle setting up seccomp filters once we're under
     * the sighand lock in case orig has changed between now and
     * then. Until then, filter must be NULL to avoid messing up
     * the usage counts on the error path calling free_task.
     */
    tsk->seccomp.filter = NULL;
#endif
    setup_thread_stack(tsk, orig); // 通过拷贝原始task线程信息,初始化新task线程信息
    clear_user_return_notifier(tsk); // 清除notifer
    clear_tsk_need_resched(tsk); // 清除调度标记
    set_task_stack_end_magic(tsk); // 设置内核栈栈尾地址,防止溢出

#ifdef CONFIG_STACKPROTECTOR
    tsk->stack_canary = get_random_canary();
#endif
    /*
     * One for us, one for whoever does the "release_task()" (usually
     * parent)
     */
    atomic_set(&tsk->usage, 2);
#ifdef CONFIG_BLK_DEV_IO_TRACE
    tsk->btrace_seq = 0;
#endif
    tsk->splice_pipe = NULL;
    tsk->task_frag.page = NULL;
    tsk->wake_q.next = NULL;
    account_kernel_stack(tsk, 1); // 技术内核栈数量
    kcov_task_init(tsk);
#ifdef CONFIG_FAULT_INJECTION
    tsk->fail_nth = 0;
#endif

#ifdef CONFIG_BLK_CGROUP
    tsk->throttle_queue = NULL;
    tsk->use_memdelay = 0;
#endif
#ifdef CONFIG_MEMCG
    tsk->active_memcg = NULL;
#endif
    return tsk;

free_stack:
    free_thread_stack(tsk);
free_tsk:
    free_task_struct(tsk);
    return NULL;


/*
 * wake_up_new_task - wake up a newly created task for the first time.
 *
 * This function will do some initial scheduler statistics housekeeping
 * that must be done for every newly created context, then puts the task
 * on the runqueue and wakes it.
 */
void wake_up_new_task(struct task_struct *p)

    struct rq_flags rf;
    struct rq *rq;
    raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
    p->state = TASK_RUNNING; // 设置状态
#ifdef CONFIG_SMP
    /*
     * Fork balancing, do it here and not earlier because:
     *  - cpus_allowed can change in the fork path
     *  - any previously selected CPU might disappear through hotplug
     *
     * Use __set_task_cpu() to avoid calling sched_class::migrate_task_rq,
     * as we're not fully set-up yet.
     */
    p->recent_used_cpu = task_cpu(p); // 设置最近跑的cpu
    __set_task_cpu(p, select_task_rq(p, task_cpu(p), SD_BALANCE_FORK, 0));
#endif
    rq = __task_rq_lock(p, &rf);
    update_rq_clock(rq); // 更新时钟
    post_init_entity_util_avg(&p->se);

    activate_task(rq, p, ENQUEUE_NOCLOCK); // 将其放入runqueue
    p->on_rq = TASK_ON_RQ_QUEUED;
    trace_sched_wakeup_new(p);
    check_preempt_curr(rq, p, WF_FORK); // 检查是否可抢占
#ifdef CONFIG_SMP
    if (p->sched_class->task_woken) 
        /*
         * Nothing relies on rq->lock after this, so its fine to
         * drop it.
         */
        rq_unpin_lock(rq, &rf);
        p->sched_class->task_woken(rq, p);
        rq_repin_lock(rq, &rf);
    
#endif
    task_rq_unlock(rq, p, &rf);

通过阅读上述代码发现新建线程或者进程的逻辑主要包括以下步骤:

如此一个新的进程 或者线程就被创建出来了,设置好想应的指令栈帧,调度到该task的时候可以直接从对应的栈帧及寄存器上下文信息中恢复运行。其中最关键的部分就是根据flag参数判断是否拷贝内存空间,打开文件以及设置用户空间线程栈以及设置线程组等操作。

深入Linux内核架构——锁与进程间通信

Linux作为多任务系统,当一个进程生成的数据传输到另一个进程时,或数据由多个进程共享时,或进程必须彼此等待时,或需要协调资源的使用时,应用程序必须彼此通信。

一、控制机制

1、竞态条件

几个进程在访问资源时彼此干扰的情况通常称之为竞态条件(race condition)。在对分布式应用编程时,这种情况是一个主要的问题,因为竞态条件无法通过系统的试错法检测。只有彻底研究源代码(深入了解各种可能发生的代码路径)并通过敏锐的直觉,才能找到并消除竞态条件。

2、临界区

对于竞态条件,其问题的本质是进程的执行在不应该的地方被中断,从而导致进程工作得不正确。对于此问题的解决方案不一定要求临界区不能中断,只要没有其他进程进入临界区,那么在临界区中执行的程序是可以中断的。确保几个进程不能同时改变共享值的禁止条件称为互斥。

大多数系统采用的方案是信号量(semaphore)的使用。信号量是由E. W. Dijkstra在1965年设计的。实质上,最初的信号量是受保护的特别变量,能够表示为正负整数,初始值为1。它有两个标准操作(up和down),这两个操作分别用于控制关键代码范围的进入和退出,且假定相互竞争的进程访问信号量机会均等。

在一个进程想要进入关键代码时,它调用down函数。这会将信号量的值减1,即将其设置为0,然后执行危险代码段(此时若有其他进程想进入该代码段调用down操作则会等待进入关键代码的进程完成操作)。在执行完操作之后,调用up函数将信号量的值加1,即重置为初始值。

信号量在用户层可以正常工作,原则上也可以用于解决内核内部的各种锁问题。但事实并非如此:性能是内核最首先的一个目标,虽然信号量初看起来容易实现,但其开销对内核来说过大,这也是内核中提供了许多不同的锁和同步机制的原因。

二、内核锁机制

在多处理器系统上,如果几个处理器同时处于核心态,理论上它们可以同时访问一个数据结构,刚好引发了竞态条件。因此,在第一个SMP功能的内核版本中,对该问题的处理是每次只允许一个处理器处于核心态,但这样效率不高。现在,内核使用了由锁组成的细粒度网络,用以明确保护各数据结构(如果处理器A在操作数据结构X,则处理器B可以执行任何其他的内核操作,但不能操作X)。

内核提供了各种锁选项,分别优化不同的内核数据使用模式:

原子操作:这些是最简单的锁操作。它们保证简单的操作,诸如计数器加1之类,可以不中断地原子执行,即使操作由几个汇编语句组成,也可以保证;

自旋锁:这些是最常用的锁选项,它们用于短期保护某段代码,以防止其他处理器的访问,在内核等待自旋锁释放时,会重复检查是否能获取锁,而不会进入睡眠状态(忙等待),如果等待时间较长,则效率显然不高;

信号量:这些是用经典方法实现的,在等待信号量释放时,内核进入睡眠状态,直至被唤醒,唤醒后,内核才重新尝试获取信号量,互斥量是信号量的特例,互斥量保护的临界区,每次只能有一个用户进入;

读者/写者锁:这些锁会区分对数据结构的两种不同类型的访问,任意数目的处理器都可以对数据结构进行并发读访问,但只有一个处理器能进行写访问(在进行写访问时,读访问是无法进行的)。

1、对整数的原子操作

内核定义了atomic_t数据类型,用作对整数计数器的原子操作的基础。从内核的角度看,这些操作相当于一条汇编语句。

为使得内核中平台独立的部分能够使用原子操作,用于操纵atomic_t类型变量的操作必须由特定于体系结构的代码提供(因为内核将标准类型进行了封装,原子变量只能借助于ATOMIC_INIT宏初始化,不能用普通运算符处理)。

内核为SMP系统提供了local_t数据类型。该类型允许在单个CPU上的原子操作。为修改此类型变量,内核提供了基本上与atomic_t数据类型相同的一组函数,只是将atomic替换为local。

2、自旋锁

自旋锁用于保护短的代码段,其中只包含少量C语句,会很快执行完毕。大多数内核数据结构都有自身的自旋锁,在处理结构中的关键成员时,必须获得相应的自旋锁。

自旋锁通过spinlock_t数据结构实现,基本上可使用spin_lock和spin_unlock操纵。(自旋锁的实现与体系结构相关,几乎全是汇编语言)

自旋锁工作情况:

  • 如果内核中其他地方尚未获得lock,则由当前处理器获取。其他处理器不能再进入lock保护的代码范围;
  • 如果lock已经由另一个处理器获得,spin_lock进入一个无限循环,重复地检查lock是否已经由spin_unlock释放(自旋锁因此得名)。如果已经释放,则获得lock,并进入临界区。

自旋锁使用注意:

  • 如果获得锁之后不释放,系统将变得不可用,所有的处理器(包括获得锁的在内),迟早需要进入锁对应的临界区,它们会进入无限循环等待锁释放,但等不到,便产生了死锁;
  • 自旋锁决不应该长期持有,因为所有等待锁释放的处理器都处于不可用状态,无法用于其他工作;
  • 内核进入到由自旋锁保护的临界区时,就停用内核抢占,在启用了内核抢占的单处理器内核中,spin_lock(基本上)等价于preempt_disable,而spin_unlock则等价于preempt_enable。

3、信号量

内核使用的信号量定义如下(用户空间信号量的实现有所不同):

1 struct semaphore {
2     atomic_t count;    //count指定了可以同时处于信号量保护的临界区中进程的数目
3     int sleepers;    //sleepers指定了等待允许进入临界区的进程的数目
4     wait_queue_head_t wait;    //wait用于实现一个队列,保存所有在该信号量上睡眠的进程的task_struct
5 };

与自旋锁相比,信号量适合于保护更长的临界区,以防止并行访问。它们不应该用于保护较短的代码范围,因为竞争信号量时需要使进程睡眠和再次唤醒,代价很高。

大多数情况下,不需要使用信号量的所有功能,只是将其用作互斥量,这是一种二值信号量。

信号量工作情况:

  • 在进入临界区时,用down对使用计数器减1,在计数器为0时,其他进程不能进入临界区;
  • 在试图用down获取已经分配的信号量时,当前进程进入睡眠,并放置在与该信号量关联的等待队列上,同时,该进程被置于TASK_UNINTERRUPTIBLE状态,在等待进入临界区的过程中无法接收信号,如果信号量没有分配,则该进程可以立即获得信号量并进入到临界区,而不会进入睡眠;
  • 在退出临界区时,必须调用up,该例程负责唤醒在信号量睡眠的某个进程,该进程然后允许进入临界区,而所有其他等待的进程继续睡眠。

除了只能用于内核的互斥量之外,Linux也提供了futex(快速用户空间互斥量,fast userspacemutex),由核心态和用户状态组合而成,为用户空间进程提供了互斥量功能。

4、RCU机制

RCU(read-copy-update)是一个同步机制,该机制记录了指向共享数据结构的指针的所有使用者。在该结构将要改变时,则首先创建一个副本(或一个新的实例),在副本中修改。在所有进行读访问的使用者结束对旧副本的读取之后,指针可以替换为指向新的、修改后副本的指针(允许读写并发进行,但不对写访问之间的相互干扰提供保护)。使用RCU要求如下:

  • 对共享资源的访问在大部分时间应该是只读的,写访问应该相对很少;
  • RCU保护的代码范围内,内核不能进入睡眠状态;
  • 受保护资源必须通过指针访问。

RCU可以保护一般指针,也可以保护双链表。以一般指针为例,假定指针ptr指向一个被RCU保护的数据结构,直接反引用指针是禁止的,首先必须调用rcu_dereference(ptr),然后反引用返回的结果,此外,反引用指针并使用其结果的代码,需要用rcu_read_lock和rcu_read_unlock调用保护起来。对于双向链表,内核也是以RCU机制为基础,提供了标准函数进行保护。此外由struct hlist_head和struct hlist_node组成的散列表也可以通过RCU保护。

5、内存和优化屏障

尽管锁足以确保原子性,但对编译器和处理器优化过的代码,锁不能永远保证时序正确。与竞态条件相比,这个问题不仅影响SMP系统,也影响单处理器计算机。

内核提供了下面几个函数,可阻止处理器和编译器进行代码重排。

mb()、rmb()、wmb()将硬件内存屏障插入到代码流程中。rmb()是读访问内存屏障。它保证在屏障之后发出的任何读取操作执行之前,屏障之前发出的所有读取操作都已经完成。wmb适用于写访问,语义与rmb类似。读者应该能猜到,mb()合并了二者的语义。

barrier插入一个优化屏障。该指令告知编译器,保存在CPU寄存器中、在屏障之前有效的所有内存地址,在屏障之后都将失效。本质上,这意味着编译器在屏障之前发出的读写请求完成之前,不会处理屏障之后的任何读写请求。

CPU仍然可以重排时序!

smb_mb()、smp_rmb()、smp_wmb()相当于上述的硬件内存屏障,但只用于SMP系统。它们在单处理器系统上产生的是软件屏障。

read_barrier_depends()是一种特殊形式的读访问屏障,它会考虑读操作之间的依赖性。如果屏障之后的读请求,依赖于屏障之前执行的读请求的数据,那么编译器和硬件都不能重排这些请求。

6、读者/写者锁

通常,任意数目的进程都可以并发读取数据结构,而写访问只能限于一个进程。因此内核提供了额外的信号量和自旋锁版本,分别称之为读者/写者信号量和读者/写者自旋锁。

读者/写者自旋锁定义为rwlock_t数据类型。必须根据读写访问,以不同的方法获取锁。

进程对临界区进行读访问时,在进入和离开时需要分别执行read_lock和read_unlock,内核会允许任意数目的读进程并发访问临界区;

write_lock和write_unlock用于写访问。内核保证只有一个写进程(此时没有读进程)能够处于临界区中。

/写信号量的用法类似。所用的数据结构是struct rw_semaphore,down_read和up_read用于获取对临界区的读访问。写访问借助于down_write和up_write进行。

7、大内核锁

大内核锁(big kernel lock)可以锁定整个内核,确保没有处理器在核心态并行运行(已经过时啦)。使用lock_kernel可锁定整个内核,对应的解锁使用unlock_kernel。SMP系统和启用了内核抢占的单处理器系统如果设置了配置选项PREEMPT_BKL,则允许抢占大内核锁。

8、互斥量

尽管信号量可用于实现互斥量的功能,信号量的通用性导致的开销通常是不必要的。因此,内核包含了一个专用互斥量的独立实现,它们不依赖信号量。内核包含互斥量的两种实现:一种是经典的互斥量,另一种是用来解决优先级反转问题的实时互斥量。

1)经典的互斥量

经典互斥量的基本数据结构定义如下:

1 struct mutex {
2 /* 1: 未锁定, 0: 锁定, 负值:锁定,可能有等待者 */
3     atomic_t count;
4     spinlock_t wait_lock;
5     struct list_head wait_list;
6 };

如果互斥量未锁定,则count为1。锁定分为两种情况:如果只有一个进程在使用互斥量,则count设置为0。如果互斥量被锁定,而且有进程在等待互斥量解锁(在解锁时需要唤醒等待进程),则count为负值。这种特殊处理有助于加快代码的执行速度,因为在通常情况下,不会有进程在互斥量上等待。

定义新的互斥量:

  • 静态互斥量可以在编译时通过使用DEFINE_MUTEX产生(与DECLARE_MUTEX区分,后者是基于信号量的互斥量);
  • mutex_init在运行时动态初始化一个新的互斥量;
  • mutex_lock和mutex_unlock分别用于锁定和解锁互斥量。

(2)实时互斥量

实时互斥量是内核支持的另一种形式的互斥量,需要在编译时通过配置选项CONFIG_RT_MUTEX显式启用。与普通的互斥量相比,它们实现了优先级继承(priority inheritance),该特性可用于解决(或在最低限度上缓解)优先级反转的影响。

对于优先级反转问题,可以通过优先级继承解决。如果高优先级进程阻塞在互斥量上,该互斥量当前由低优先级进程持有,那么低优先级进程的优先级会临时提高到高优先级进程的优先级。

实时互斥量的定义非常接近于普通互斥量:

1 struct rt_mutex {
2     spinlock_t wait_lock;
3     struct plist_head wait_list;
4     struct task_struct *owner;
5 };

互斥量的所有者通过owner指定,wait_lock提供实际的保护,所有等待的进程都在wait_list中排队。与普通互斥量相比,决定性的改变是等待列表中的进程按优先级排序。在等待列表改变时,内核可相应地校正锁持有者的优先级。这需要到调度器的一个接口,可由函数rt_mutex_setprio提供。该函数更新动态优先级task_struct->prio,而普通优先级task_struct->normal_priority不变。

9、近似的per_CPU计数器

如果系统安装有大量CPU,计数器可能成为瓶颈:每次只有一个CPU可以修改其值;所有其他CPU都必须等待操作结束,才能再次访问计数器。如果计数器频繁访问,则会严重影响系统性能。

对某些计数器,没有必要时时了解其准确的数值。这种计数器的近似值与准确值,作用上没什么差别,可以利用这种情况,引入per-CPU计数器,加速SMP系统上计数器的操作。如图1所示,计数器的准确值存储在内存中某处,准确值所在内存位置之后是一个数组,每个数组项对应于系统中的一个CPU。

 

1 近似per-CPU计数器的数据结构

如果一个处理器想要修改计数器的值(加上或减去某个值n),它不会直接修改计数器的值,因为这需要防止其他的CPU访问计数器(这是一个费时的操作)。相反,所需的修改将保存到与计数器相关的数组中特定于当前CPU的数组项。(举例:,如果计数器应该加3,那么数组中对应的数组项为+3。如果同一个CPU在其他时间需要从计数器减去某个值(假定是5),它也不会对计数器直接操作,而是操作数组中特定于CPU的项:将3减去5,新值为-2。任何处理器读取计数器值时,都不是完全准确的。如果原值为15,在经过前述的操作之后应该是13,但仍然是15。如果只需要大致了解计数器的值,13也算得上是15的一个比较好的近似了。)

如果某个特定于CPU的数组元素修改后的绝对值超出某个阈值,则认为这种修改有问题,将随之修改计数器的值(这种改变很少发生)。在这种情况下,内核需要确保通过适当的锁机制来保护这次访问。

只要计数器改变适度,这种方案中读操作得到的平均值会相当接近于计数器的准确值。

per-CPU计数器如下:

1 struct percpu_counter {
2     spinlock_t lock;
3     long count;
4     long *counters;
5 };

count是计数器的准确值,lock是一个自旋锁,用于在需要准确值时保护计数器。counters数组中各数组项是特定于CPU的,该数组缓存了对计数器的操作。

10、锁竞争与细粒度锁

Linux在多CPU系统上的可伸缩性已经成为一个非常重要的目标。在对内核代码设计锁规则时,特别需要考虑这个问题。锁需要满足下面两个目的(不过二者通常很难同时实现):

必须防止对代码的并发访问,否则将导致失败;

对性能的影响必须尽可能小。

对于内核频繁使用的数据,同时满足这两个要求是非常复杂的,如果一整个数据结构都由一个锁保护,那么在内核的某个部分需要获取锁的时候,该锁已经被系统的其他部分获取的概率很高,这种情况下会出现较多的锁竞争(lock contention),该锁也会成为内核的一个热点。对此,将数据结构标识为各个独立的部分,使用多个锁来保护,这种解决方案称为细粒度锁

细粒度锁在较大的计算机上对提高可伸缩性很有好处,但也有两个弊端:

获取多个锁会增加操作的开销,特别是在较小的SMP计算机上;

在通过多个锁保护一个数据结构时,很自然会出现一个操作需要同时访问两个受保护区域的情形,因而需要同时持有多个锁,这要求必须遵守某种锁定次序,必须按序获取和释放锁,否则,仍然会导致死锁。

三、System V进程间通信

Linux使用System V(SysV)引入的机制,来支持用户进程的进程间通信和同步。

1、System V机制

System V UNIX的3种进程间通信(IPC)机制(信号量、消息队列、共享内存),都使用了全系统范围的资源,可以由几个进程同时共享。

在各个独立进程能够访问SysV IPC对象之前,IPC对象必须在系统内唯一标识。为此,每种IPC结构在创建时分配了一个号码,称为魔数。凡知道这个魔数的各个程序,都能够访问对应的结构。如果独立的应用程序需要彼此通信,则通常需要将该魔数永久地编译到程序中。

在访问IPC对象时,系统采用了基于文件访问权限的一个权限系统。每个IPC对象都有一个用户ID和一个组ID,依赖于产生IPC对象的程序在何种UID/GID之下运行。读写权限在初始化时分配。类似于普通的文件,这些控制了3种不同用户类别的访问:所有者、组、其他。

要创建一个授予所有可能访问权限的信号量(所有者、组、其他用户都有读写权限),则必须指定标志0666。

2、信号量

1)使用System V信号量

System V的信号量不再当作是用于支持原子执行预定义操作的简单类型变量,它是指一整套信号量,可以允许几个操作同时进行(用户看上去是原子的)。也可以请求只有一个信号量的信号量集合,并定义函数模拟原始信号量的简单操作。

2)数据结构

内核使用了几个数据结构来描述所有注册信号量的当前状态,并建立了一种网状结构。它们不仅负责管理信号量及其特征(值、读写权限,等等),还负责通过等待列表将信号量与等待进程关联起来。

初始的默认的IPC命名空间通过ipc_namespace的静态实例init_ipc_ns实现。每个命名空间都包含如下信息:

1 struct ipc_namespace {
2 ...
3     struct ipc_ids *ids[3];
4 /* 资源限制 */
5 ...
6 }

 

这里略去了与监视资源消耗和设置资源限制相关的很多数据结构成员(比如共享内存页的最大数目、共享内存段的最大长度、消息队列的最大数目等)。数组ids的每个元素对应于一种IPC机制:信号量、消息队列、共享内存(按顺序),每个数组项指向一个struct ipc_ids的实例,用于跟踪各类别现存的IPC对象。为防止对每个类别都需要查找对应的正确数组索引,内核提供了辅助函数msg_ids、shm_ids和sem_ids。

struct ipc_ids定义如下:

1 struct ipc_ids {
2     int in_use;    //保存了当前使用中IPC对象的数目
3     unsigned short seq;    //seq和seq_max用于连续产生用户空间IPC ID(不等同于序号)
4     unsigned short seq_max;
5     struct rw_semaphore rw_mutex;    //一个内核信号量,用于实现信号量操作,避免用户空间中的竞态条件
6     struct idr ipcs_idr;
7 };

每个IPC对象都由kern_ipc_perm的一个实例表示,每个对象都有一个内核内部ID,ipcs_idr用于将ID关联到指向对应的kern_ipc_perm实例的指针。使用中IPC对象的数目可能动态地增长和缩减,内核提供了一个类似于基数树的标准数据结构用于管理该信息。

 1 struct kern_ipc_perm
 2 {
 3     int id;
 4     key_t key;    //保存了用户程序用来标识信号量的魔数
 5     uid_t uid;        //指所有者的用户ID
 6     gid_t gid;        //指所有者的组ID
 7     uid_t cuid;    //保存了产生信号量的进程的用户ID
 8     gid_t cgid;    //保存了产生信号量的进程的组ID
 9     mode_t mode;    //保存了位掩码,指定了所有者、组、其他用户的访问权限
10     unsigned long seq;    //分配IPC对象时使用的序号
11 };

该结构不仅可用于信号量,还可以用于其他的IPC机制。该结构不足以保存信号量所需的所有信息,各进程的task_struct实例中有一个与IPC相关的成员:

1 struct task_struct {
2 ...
3 #ifdef CONFIG_SYSVIPC
4 /* ipc相关 */
5     struct sysv_sem sysvsem;
6 #endif
7 ...
8 };

只有设置了配置选项CONFIG_SYSVIPC时,SysV相关代码才会编译到内核中。sysv_sem数据结构封装了一个成员struct sem_undo_list *undo_list用于撤销信号量(用于崩溃进程修改了信号量状态的情况)。

sem_queue是另一个数据结构,用于将信号量与睡眠进程关联起来,该进程想要执行信号量操作,但目前不允许执行。

 1 struct sem_queue {
 2     struct sem_queue * next; /* 队列中下一项 */
 3     struct sem_queue ** prev; /* 队列中的前一项,对于第一项有*(q->prev) == q */
 4     struct task_struct* sleeper; /* 睡眠的进程 */
 5     struct sem_undo * undo; /* 用于撤销的结构 */
 6     int pid; /* 请求信号量操作的进程ID。 */
 7     int status; /* 操作的完成状态 */
 8     struct sem_array * sma; /* 操作的信号量数组 */
 9     int id; /* 内部信号量ID */
10     struct sembuf * sops; /* 待决操作数组 */
11     int nsops; /* 操作数目 */
12     int alter; /* 操作是否改变了数组? */
13 };

系统中每个信号量集合,都对应于sem_array数据结构的一个实例,该实例用于管理集合中的所有信号量,sem_array结构如下:

 1 struct sem_array {
 2     struct kern_ipc_perm sem_perm; /* 权限,参见ipc.h */
 3     time_t sem_otime; /* 最后一次信号量操作的时间 */
 4     time_t sem_ctime; /* 最后一次修改的时间 */
 5     struct sem *sem_base; /* 指向数组中第一个信号量的指针 */
 6     struct sem_queue *sem_pending; /* 需要处理的待决操作 */
 7     struct sem_queue **sem_pending_last; /* 上一个待决操作 */
 8     struct sem_undo *undo; /* 该数组上的撤销请求 */
 9     unsigned long sem_nsems; /* 数组中信号量的数目 */
10 };

2给出了所涉及的各个数据结构之间的相互关系。

 

2 信号量各数据结构之间的相互关系

kern_ipc_perm是用于管理IPC对象的数据结构的第一个成员,不仅对信号量是这样,消息队列和共享内存对象也是如此。

3)实现系统调用

所有对信号量的操作都使用一个名为ipc的系统调用执行。该调用不仅用于信号量,也用于操作消息队列和共享内存。其第一个参数用于将实际工作委托给其他函数。用于信号量的函数如下所示。

  • SEMCTL执行信号量操作,并由sys_semctl实现;
  • SEMGET读取信号量ID,相关的实现由sys_semget提供;
  • SEMOP和SEMTIMEDOP负责增加和减少信号量值,后者可以指定超时时间限制。

4)权限检查

IPC对象的保护机制,与普通的基于文件的对象相同。访问权限可以分别对对象的所有者、所有者所在组和所有其他用户指定(可能的权限包括读、写、执行)。函数ipcperms负责检查对任意IPC对象的某种操作是否有权限进行。

3、消息队列

进程之间通信的另一个方法是交换消息。这是使用消息队列机制完成的,其实现基于System V模型。消息队列的功能原理相对简单,如图3所示。

3 System V消息队列的功能原理

产生消息并将其写到队列的进程通常称之为发送者,而一个或多个其他进程(逻辑上称之为接收者)则从队列获取信息。各个消息包含消息正文和一个(正)数,以便在消息队列内实现几种类型的消息。接收者可以根据该数字检索消息(比如可以指定只接受编号1的消息,或接受编号不大于5的消息)。在消息已经读取后,内核将其从队列删除。即使几个进程在同一信道上监听,每个消息仍然只能由一个进程读取。

同一编号的消息按先进先出次序处理。放置在队列开始的消息将首先读取。但如果有选择地读取消息,则先进先出次序就不再适用。

消息队列也是使用前述信号量哪些数据结构实现,起始点是当前命名空间的适当的ipc_ids实例。内部的ID号形式上关联到kern_ipc_perm实例,在消息队列的实现中,需要通过类型转换获得不同的数据类型(struct msg_queue)。该结构定义如下:

 1 struct msg_queue {
 2     struct kern_ipc_perm q_perm;
 3     time_t q_stime; /* 上一次调用msgsnd发送消息的时间 */
 4     time_t q_rtime; /* 上一次调用msgrcv接收消息的时间 */
 5     time_t q_ctime; /* 上一次修改的时间 */
 6     unsigned long q_cbytes; /* 队列上当前字节数目 */
 7     unsigned long q_qnum; /* 队列中的消息数目 */
 8     unsigned long q_qbytes; /* 队列上最大字节数目 */
 9     pid_t q_lspid; /* 上一次调用msgsnd的pid */
10     pid_t q_lrpid; /* 上一次接收消息的pid */
11     struct list_head q_messages;
12     struct list_head q_receivers;
13     struct list_head q_senders;
14 };

3个标准的内核链表用于管理睡眠的发送者(q_senders)、睡眠的接收者(q_receivers)和消息本身(q_messages)。各个链表都使用独立的数据结构作为链表元素。

q_messages中的各个消息都封装在一个msg_msg实例中。

1 struct msg_msg {
2     struct list_head m_list;
3     long m_type;    //指定了消息类型,用于支持前文所述消息队列中不同的消息类型。
4     int m_ts; /* 消息正文长度 */
5     struct msg_msgseg* next;    //如果保存超过一个内存页的长消息,则需要next
6 /* 接下来是实际的消息 */
7 };

结构中没有指定存储消息自身的字段。因为每个消息都(至少)分配了一个内存页,msg_msg实例则保存在该页的起始处,剩余的空间可用于存储消息正文,如图4所示。从内存页的长度,减去msg_msg结构的长度,即可得到msg_msg页中可用于消息正文的最大字节数目。

4 内存中IPC消息的管理

消息正文紧接着该数据结构的实例之后存储。使用next,可以使消息分布到任意数目的页上。在通过消息队列通信时,发送进程和接收进程都可以进入睡眠:如果消息队列已经达到最大容量,则发送者在试图写入消息时会进入睡眠;如果队列中没有消息,那么接收者在试图获取消息时会进入睡眠。

睡眠的发送者放置在msg_queue的q_senders链表中,链表元素使用下列数据结构:

1 struct msg_sender {
2     struct list_head list;    //链表元素
3     struct task_struct* tsk;    //指向对应进程的task_struct的指针
4 };

 

 

q_receivers链表中用于保存接收进程的数据结构要稍长一点。

1 struct msg_receiver {
2     struct list_head r_list;
3     struct task_struct *r_tsk;
4     int r_mode;
5     long r_msgtype;
6     long r_maxsize;
7     struct msg_msg *volatile r_msg;
8 };

其中不仅保存了指向对应进程的task_struct的指针,还包括了对预期消息的描述,以及指向msg_msg实例的一个指针(消息可用时,该指针指定了复制数据的目标地址)。

5是消息队列所涉及数据结构之间的相互关系(忽略睡眠的发送进程链表)。

5 System V消息队列的数据结构

4、共享内存

与信号量和消息队列相比,共享内存没有本质性的不同。

  • 应用程序请求的IPC对象,可以通过魔数和当前命名空间的内核内部ID访问;
  • 对内存的访问,可能受到权限系统的限制;
  • 可以使用系统调用分配与IPC对象关联的内存,具备适当授权的所有进程,都可以访问该内存。

内核的实现采用了与信号量和消息队列非常类似的概念,相关数据结构关系如图6所示。

6 System V共享内存的数据结构

smd_ids全局变量的entries数组中保存了kern_ipc_perm和shmid_kernel的组合,以便管理IPC对象的访问权限。对每个共享内存对象都创建一个伪文件,通过shm_file连接到shmid_kernel的实例。内核使用shm_file->f_mapping指针访问地址空间对象(struct address_space),用于创建匿名映射。还需要设置所涉及各进程的页表,使得各个进程都能够访问与该IPC对象相关的内存区域。

四、其他IPC机制

SysV IPC通常只对应用程序员有意义,但对shell的用户,信号和管道更常用。

1、信号

SysV机制相比,信号是一种比较原始的通信机制,其底层概念非常简单,kill命令根据PID向进程发送信号。信号通过-s sig指定,是一个正整数,最大长度取决于处理器类型。

进程必须设置处理程序例程来处理信号。这些例程在信号发送到进程时调用(进程可以决定阻塞某些信号,但有几个信号的行为无法修改,如SIGKILL)。如果没有显式设置处理程序例程,内核则使用默认的处理程序实现。(init进程属于特例,内核会忽略发送给该进程的SIGKILL信号。)

1)实现信号处理程序

sigaction系统调用用于设置新的处理程序。如果没有为某个信号分配用户定义的处理程序函数,内核会自动设置预定义函数,提供合理的标准操作来处理相应的情况。

sigaction类型中用于描述处理程序的字段,其定义是平台相关的,但在所有体系结构上几乎都相同。

1 struct sigaction {
2     __sighandler_t sa_handler;    //一个指向内核在信号到达时调用的处理程序函数的指针
3     unsigned long sa_flags;    //包含了额外的标志,用于指定信号处理方式的一些约束
4 ...
5     sigset_t sa_mask; //包含了一个位掩码,每个比特位对应于系统中的一个信号
6 };

信号处理程序的函数原型如下:

1 typedef void __signalfn_t(int);
2 typedef __signalfn_t __user *__sighandler_t;

其参数是信号的编号,因此可以使用同一个处理程序函数处理不同的信号。

信号处理程序使用sigaction系统调用设置,该调用将借助用户定义的处理程序函数替换SIGTERM的默认处理程序。

2)实现信号管理

所有信号相关的数据都是借助于链式数据结构管理的,其入口是task_struct结构,其中包含了各个与信号相关的字段。

 1 struct task_struct {
 2 ...
 3 /* 信号处理程序 */
 4     struct signal_struct *signal;
 5     struct sighand_struct *sighand;
 6     sigset_t blocked;
 7     struct sigpending pending;
 8     unsigned long sas_ss_sp;
 9     size_t sas_ss_size;
10 ...
11 };

信号处理发生在内核中,但设置的信号处理程序是在用户状态运行,通常,信号处理程序使用所述进程在用户状态下的栈。但POSIX强制要求提供一种选项,在专门用于信号处理的栈上运行信号处理程序,这个附加的栈(必须通过用户应用程序显式分配),其地址和长度分别保存在sas_ss_sp和sas_ss_size。

用于管理设置的信号处理程序的信息的sighand_struct如下所示:

1 struct sighand_struct {
2     atomic_t count;    //保存了共享该结构实例的进程数目
3     struct k_sigaction action[_NSIG];    //保存设置的信号处理程序,_NSIG指定了可以处理的不同信号的数目
4 } ;

所有阻塞信号由task_struct的blocked成员定义,所使用的sigset_t数据类型是一个位掩码,所包含的比特位数目必须(至少)与所支持的信号数目相同,其数据结构为:

1 typedef struct {
2     unsigned long sig[_NSIG_WORDS];
3 } sigset_t;

pending是task_struct中与信号处理相关的最后一个成员。它建立了一个链表,包含所有已经引发、仍然有待内核处理的信号。它们使用了下列数据结构:

1 struct sigpending {
2     struct list_head list;    //通过双链表管理所有待决信号
3     sigset_t signal;        //位掩码,指定了仍然有待处理的所有信号的编号
4 };

7为各结构体之间的关系。

深入Linux内核架构——锁与进程间通信

深入Linux内核架构-进程管理和调度-脑图

深入Linux内核架构——进程管理和调度(上)

学习笔记 深入理解Linux内核第三版 —— 第三章 进程

Linux下的进程类别(内核线程轻量级进程和用户进程)以及其创建方式--Linux进程的管理与调度

深入Linux内核架构 - 内核之中数据结构之间的关系图 & 设备驱动程序(转)