Linux内核设计与实现——线程

概述

最近开始学习Linux内核,主要参考书籍为《Linux内核设计与实现》,所以本系列文章大章节和小章节会遵从原书结构,再辅以其他书籍或网上资料对其未理解部分进行补充。

本系列定位为初级文档,不会详细阐述实现原理,只讲解概念和逻辑。

本章目录

Linux内核设计与实现——线程

进程概念

进程是Linux内核最基本的抽象之一,它是处于执行期的程序(OS中的概念),或者说“进程=程序+执行”。但是进程并不仅局限于一段可执行代码(代码段),它还包括进程需要的其他资源,例如打开的文件挂起的信号量内存管理处理器状态一个或者多个执行线程和数据段等。

线程被称为轻量级进程(lightweight process),它是操作系统调度的最小单元,通常一个进程可以拥有多个线程

线程和进程的区别在于进程拥有独立的资源空间,而线程则共享进程的资源空间。换句话说,进程与进程的地址空间是独立的,线程共享进程的地址空间

Linux内核设计与实现——线程


Linux内核并没有对线程有特别的调度算法或定义特别的数据结构来标识线程,线程和进程都使用相同的进程PCB数据结构。对Linux 而言,线程只不过是一种特殊的进程罢了。

在Linux系统中,通常使用fork()创建一个全新的线程。调用fork()的进程称为父进程,新产
生的进程称为子进程。在该调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开
始执行。

fork() 系统调用从内核返回两次:一次回到父进程另一次回到新产生的子进程。

Linux内核设计与实现——线程

在这儿引入一个sample

/* sample1:通过fork创建线程 */
#include<sys/types.h> 
#include<unistd.h> 
#include<stdio.h> 
#include<stdlib.h> 
#include<errno.h> 

int main() 

  printf("this is fork sample %dn",getpid());
  pid_t pid;/*pid 进程id号*/ 

  pid=fork();/*创建一个新进程*/ 
  printf("pid:%dn",pid);
  if(pid==0/*返回0为子进程*/ 
  { 
   printf("Return pid is %dn",pid); 
   printf("This is son process!  pid is:%dn",getpid()); 

  } 
  else if(pid>0)/*返回大于0为父进程*/ 
  { 
    printf("Return pid is %dn",pid); 
    printf("This is parent process!  pid is:%dn",getpid()); 
    waitpid(pid,NULL,0);/*等待子进程退出*/
  } 
  else 
  { 
     perror("fork() error!"); 
     exit(0); 
  } 
}

输出

this is fork sample 183
pid:184
pid:0
Return pid is 0
Return pid is 184
This is parent process!  pid is:183
This is son process!  pid is:184

可以看到从fork() 父进程返回了一次,子进程返回了一次。

父进程返回子进程ID,子进程返回0。

子进程也是从fork()处继续执行的,而不是从头执行!

在引入两个思考题

#include <unistd.h>
#include <stdio.h>
int main(void)
{
   int i=0;
   printf("i son/pa ppid pid  fpidn");
   //ppid指当前进程的父进程pid
   //pid指当前进程的pid,
   //fpid指fork返回给当前进程的值
   for(i=0;i<2;i++){
       pid_t fpid=fork();
       printf("pid:%dn",fpid);
       if(fpid==0)
           printf("%d child  %4d %4d %4dn",i,getppid(),getpid(),fpid);
       else
           printf("%d parent %4d %4d %4dn",i,getppid(),getpid(),fpid);
   }
   return 0;
}

总共打印多少次pid:xx ?

总共打印了6次pid:xx

推导过程引用网上资料:

Linux内核设计与实现——线程


Linux内核设计与实现——线程


对于有for循环的情况,总进程为2^0+2^1+….+2^n,子进程为(2^n)-1。

#include <stdio.h>
#include <sys/types.h>
int main()
{
    fork();
    fork();
    printf("hellon");
    return 0;
}

总共创建了多少个hello

答案是4个。

推导流程如下:

Linux内核设计与实现——线程


第一次fork产生2进程,第二次fork产生了3,4进程。

可以简单总结一个规律,对于此类情况,总进程数为2^n,该例子中,n为2,所以总进程为4。

思考题结束,进入正题。

创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec()这组函数
就可以创建新的地址空间,井把新的程序载入其中。

#include<stdio.h>
#include<unistd.h>

int main()
{
    int i;

    printf("I am EXEC.c called by execv() ");
    printf("n");
    return 0;
}

将上述文件编译为可执行文件exec

#include <stdio.h>
#include <sys/types.h>
int main()
{
    int pid,status;

    pid = fork();
    if(pid == 0){
        char *args[]={"./exec",NULL};
        execv(args[0],args);
        printf("exec failed!n");
        exit(1);
    }
    else{
        printf("parent waitingn");
        wait(&status);
        printf("the child exited with status %dn",status);
    }
    exit(0);
}

输出

parent waiting
I am EXEC.c called by execv() 
the child exited with status 0

子进程通过exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。(线程退出章节会详讲

进程描述符及任务结构

内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中。

链表中的每一项都是类型为task_struct、称为进程描述符(process descriptor ),用于存放具体进程的所有信息。

Linux内核设计与实现——线程


进程描述符中一个重要成员为 thread_info ,记录部分进程信息的结构体,其中包括了进程上下文信息。

Linux内核设计与实现——线程


task_struct数据结构中的stack成员指向thread_union结构(Linux内核通过thread_union联合体来表示进程的内核栈)

struct task_struct
{

    // ...
    void *stack;    //  指向内核栈的指针
    // ...
};
union thread_union {
    struct thread_info thread_info;
    unsigned long stack[THREAD_SIZE/sizeof(long)];  
};
struct thread_info {
  struct pcb_struct    pcb;        /* palcode state */
     struct task_struct  *task;      /* main task structure */  /*这里很重要,task指针指向的是所创建的进程的struct task_struct
    unsigned int        flags;      /* low level flags */

    unsigned int        ieee_state; /* see fpu.h */

    struct exec_domain  *exec_domain;   /* execution domain */  /*表了当前进程是属于哪一种规范的可执行程序,
                                                                        //不同的系统产生的可执行文件的差异存放在变量exec_domain中
    mm_segment_t        addr_limit; /* thread address space */

    unsigned        cpu;        /* current CPU */
    int         preempt_count; /* 0 => preemptable, <0 => BUG */

    int bpt_nsaved;
    unsigned long bpt_addr[2];      /* breakpoint handling  */
    unsigned int bpt_insn[2];

    struct restart_block    restart_block;
};

这样的好处是可以通过stackthread_infotask_struct任意一个数据结构的地址,就可以很快得到另外两个数据的地址。

实际上,current宏也是这样去设计的。

内核通过一个唯一的进程标识值(PID)来标识每个进程。

通过current宏查找到当前正在运行进程的进程描述符的速度就显得尤为重要。

参考在ARM下的实现

#define get_current() (current_thread_info()->task)
#define current get_current()

-----

register unsigned long current_stack_pointer asm ("sp");

static inline struct thread_info *current_thread_info(void)
{
    return (struct thread_info *)
        (current_stack_pointer & ~(THREAD_SIZE - 1));
}

根据SP获取到thread_info的地址,然后根据成员获取到current

看一个实际的例子:

把断点打在了start_kernel函数,对init_task(详见下文)进行分析,首先获取SP指针的值

(gdb) i registers sp
**sp             0x81001ff8          0x81001ff8 <init_thread_union+8184>

通过sp值计算出thread_info的值

0x81001ff8&(~(0x1FFF)) = 0x81000000

根据thread_info获取到task结构体的地址

(gdb) p (*(struct thread_info *)(0x81000000))->task
(struct task_struct *) 0x81005ec0 <init_task>
(gdb) p &init_task
(struct task_struct *) 0x81005ec0 <init_task>
(gdb) p (*((struct task_struct *)0x81005ec0))->stack
(void *) 0x81000000 <init_thread_union>

可以看到我们根据sp的值就推导出来thread_infoinit_taskstack的值。

通过反汇编文件,也能验证数值是正确的。

Linux内核设计与实现——线程


进程状态

进程描述符中的 state 域描述了进程的当前状态。系统中的每个进程都必然处于五种进程状态中的一种。

  • TASK_RUNING(运行)——进程是可执行的;它或者正在执行,或者在等待执行。

  • TASK_INTERRUPTILE(可中断)——进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一且这些条件达成,内核就会把进程状态设置为运行。

  • TASK_UNINTERRUPTIBLE (不可中断)——与可中断的等待状态类似,但有一个例外,把信号传递到睡眠进程不能改变它的状态。

  • __TASK_TRACED——其他进程跟踪的进程

  • __TASK_STOPPED——进程停止执行;通常这种状态发生在接收到SIGSTOPSIGTSTPSIGTTINSIGTTOU 等信号的时候。

还有两个进程状态是既可以存放在进程描述符的state字段中,也可以存放在exit_state
字段中。从这两个字段的名称可以看出,只有当进程的执行被终止时,进程的状态才会变
为这两种状态中的一种:

  • EXIT_ZOMBIE(僵死状态)——进程的执行被终止,但是,父进程还没有发布wait()或waitpid()系统调用来返回有关死亡进程的信息。

  • EXIT_DEAD(僵死撤消状态)——最终状态:由于父进程刚发出wait()waitpid()系统调用,因而进程由系统删除。

Linux内核设计与实现——线程


进程创建

进程创建主要分为第一个任务的创建和fork()两个章节进行描述。

第一个任务

Linux内核在启动时会有一个init_task进程,它是系统所有进程的“鼻祖”,称为0号进程。

init_task进程是静态定义的,其他进程在分配的时候为动态分配。

/* init_task.c */
struct task_struct init_task = INIT_TASK(init_task);

union thread_union init_thread_union __init_task_data = {
#ifndef CONFIG_THREAD_INFO_IN_TASK
    INIT_THREAD_INFO(init_task)
#endif
};

/* init_task.h */
#define INIT_TASK(tsk)    
{                                    
    INIT_TASK_TI(tsk)                       
    .state      = 0,                        
    .stack      = init_stack,                   
    .usage      = ATOMIC_INIT(2),               
    .flags      = PF_KTHREAD,                   
    .prio       = MAX_PRIO-20,                  
    .static_prio    = MAX_PRIO-20,                  
    .normal_prio    = MAX_PRIO-20,                  
    .policy     = SCHED_NORMAL,                 
    .cpus_allowed   = CPU_MASK_ALL,                 
    .nr_cpus_allowed= NR_CPUS,                  
    .mm     = NULL,                     
    .active_mm  = &init_mm,                 
    .restart_block = {                      
        .fn = do_no_restart_syscall,                
    },                              
    .se     = {                     
        .group_node     = LIST_HEAD_INIT(tsk.se.group_node),    
    },                              
    .rt     = {                     
        .run_list   = LIST_HEAD_INIT(tsk.rt.run_list),  
        .time_slice = RR_TIMESLICE,             
    },                              
    .tasks      = LIST_HEAD_INIT(tsk.tasks),            
    .thread     = INIT_THREAD,     
    ....

    }

在这个结构体中,我们重点关注stack,thread成员。

stack成员是由init_stack定义的,跟踪该宏的实现。

// arch/arm/include/asm/thread_info.h
#define init_stack        (init_thread_union.stack)

// init/init_task.c
union thread_union init_thread_union __init_task_data = {
#ifndef CONFIG_THREAD_INFO_IN_TASK
    INIT_THREAD_INFO(init_task)
#endif
};

// include/linux/init_task.h
#define __init_task_data __attribute__((__section__(".data..init_task")))

--- 

往这条链路分析

[arch/arm/kernel/vmlinux.lds.S]

SECTIONS
{
     …

    .data : AT(__data_loc) {
        _data = .;        /* address in memory */
        _sdata = .;

        /*
         * first, the init task union, aligned
         * to an 8192 byte boundary.
         */

        INIT_TASK_DATA(THREAD_SIZE)

        …
        _edata = .;
    }
}

[arch/arm/include/asm/thread_info.h]

#define THREAD_SIZE_ORDER    1
#define THREAD_SIZE    (PAGE_SIZE << THREAD_SIZE_ORDER)
#define THREAD_START_SP        (THREAD_SIZE - 8)

[include/asm-generic/vmlinux.lds.h]

#define INIT_TASK_DATA(align)                        
      . = ALIGN(align);                        
      *(.data..init_task)

由链接文件可以看到data段预留了8KB的空间用于内核栈,存放在data段的“.data..init_task”中。

__init_task_data宏会直接读取“.data..init_task”段内存,并且存放了一个thread_union联合数据结构,从联合数据结构可以看出其分布情况:开始的地方存放了struct thread_info数据结构,顶部往下的空间用于内核栈空间。

也就说在内核初始化时用的栈空间就是0号线程栈。

后续,init_task会逐渐迭代为swapper/x的进程。

idle_cpu(cpu)——>fork_idle(cpu)——>init_idle(task, cpu)——>cpu_startup_entry(enum cpuhp_state state)

void init_idle(struct task_struct *idle, int cpu)
{
    struct rq *rq = cpu_rq(cpu);
    unsigned long flags;

    raw_spin_lock_irqsave(&idle->pi_lock, flags);
    raw_spin_lock(&rq->lock);

    __sched_fork(0, idle);
    idle->state = TASK_RUNNING;
    idle->se.exec_start = sched_clock();
    rcu_read_lock();
    __set_task_cpu(idle, cpu);  // 将进程分配到不同cpu
    rcu_read_unlock();

    /*
     * The idle tasks have their own, simple scheduling class:
     */

    idle->sched_class = &idle_sched_class;
    ftrace_graph_init_idle_task(idle, cpu);
    vtime_init_idle(idle, cpu);
#ifdef CONFIG_SMP
    sprintf(idle->comm, "%s/%d", INIT_TASK_COMM, cpu); // 进程改名
#endif
}

可以看到该函数的参数为cpu,也就是说在每个cpu上都会运行一个idle线程,最后会把init_task改名为swapper/x,其中x就表示对应的cpu number。

最后执行cpu_startup_entry函数

void cpu_startup_entry(enum cpuhp_state state)
{
    /*
     * This #ifdef needs to die, but it's too late in the cycle to
     * make this generic (arm and sh have never invoked the canary
     * init for the non boot cpus!). Will be fixed in 3.11
     */

#ifdef CONFIG_X86
    /*
     * If we're the non-boot CPU, nothing set the stack canary up
     * for us. The boot CPU already has it initialized but no harm
     * in doing it again. This is a good place for updating it, as
     * we wont ever return from this function (so the invalid
     * canaries already on the stack wont ever trigger).
     */

    boot_init_stack_canary();
#endif
    arch_cpu_idle_prepare();
    cpuhp_online_idle(state);
    cpu_idle_loop();
}

cpu_idle_loop中进行死循环,执行一些idle相关的工作。

对于Linux内核来说,进程的“鼻祖”是idle进程,也称为swapper进程;但对用户空间来说,进程的“鼻祖”是init进程,所有用户空间进程都由init进程创建和派生。

fork描述

在Linux系统中,进程或线程是通过forkvforkclone等系统调用来建立的。

(1)fork:子进程是父进程的一个副本,采用了写时复制(下文会讲)的技术。

(2)vfork:用于创建子进程,之后子进程立即调用execve以装载新程序的情况。为了避免复制物理页,父进程会睡眠等待子进程装载新程序。现在fork采用了写时复制的技术,vfork失去了速度优势,已经被废弃。(POSIX.1 2008标准里面移除了vfork())

(3)clone:可以精确地控制子进程和父进程共享哪些资源。这个系统调用的主要用处是可供pthread库用来创建线程。

clone是功能最齐全的函数,参数多,使用复杂,forkclone的简化函数。

这3个系统的调用都是通过同一个函数来实现,即do_fork()函数。

[kernel/fork.c]

long do_fork(unsigned long clone_flags, 
                        unsigned long stack_start, 
                        unsigned long stack_size, 
                        int __user *parent_tidptr, 
                        int __user *child_tidptr)

do_fork()函数有5个参数,具体含义如下。

clone_flags:创建进程的标志位集合。

stack_start:参数stack_start只在创建线程时有意义,用来指定新线程的用户栈的起始地址。

stack_size:参数stack_size只在创建线程时有意义,用来指定新线程的用户栈的长度。这个参数已经废弃。

parent_tidptrchild_tidptr:指向用户空间中地址的两个指针,分别指向父子进程的PID。

clone_flags定义在sched.h文件中。

[include/uapi/linux/sched.h]

/*
 * cloning flags:
 */

#define CSIGNAL    0x000000ff    /* signal mask to be sent at exit */
#define CLONE_VM   0x00000100    /* 父子进程之间共享内存空间,一个进程对全局变量改动,另外一个进程也可以看到 */
#define CLONE_FS   0x00000200    /* 父子进程之间共享相同的文件系统 */
#define CLONE_FILES    0x00000400  /* 父子进程共享相同的文件描述符 */
#define CLONE_SIGHAND  0x00000800  /* 父子进程共享相同的信号处理等相关信息 */
#define CLONE_PTRACE 0x00002000    /* 父进程被trace,子进程也同样被trace */
#define CLONE_VFORK    0x00004000 /* 父进程被挂起,直到子进程释放了虚拟内存资源 */
#define CLONE_PARENT   0x00008000/* 新进程和创建它的进程是兄弟关系,而不是父子关系 */
#define CLONE_THREAD    0x00010000/* 父子进程共享相同的线程群*/

fork实现:

 `do_fork(SIGCHLD, 00NULLNULL);`

vfork实现:

`do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 00NULLNULL);`

clone实现:

`do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);`

内核线程:

 `do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn, (unsigned long)arg, NULLNULL);`

pthread实现:

const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
               | CLONE_SIGHAND | CLONE_THREAD
               | CLONE_SETTLS | CLONE_PARENT_SETTID
               | CLONE_CHILD_CLEARTID
               | 0);

  TLS_DEFINE_INIT_TP (tp, pd);

  if (__glibc_unlikely (ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS,
                    clone_flags, pd, &pd->tid, tp, &pd->tid)
            == -1))

fork只使用SIGCHLD标志位,在子进程终止后发送SIGCHLD信号通知父进程。

fork是重量级调用,为子进程建立了一个基于父进程的完整副本,然后子进程基于此运行。

vfork的实现比fork多了两个标志位,分别是CLONE_VFORKCLONE_VMCLONE_VFORK表示父进程会被挂起,直至子进程释放虚拟内存资源。CLONE_VM表示父子进程运行在相同的内存空间中。clone用于创建线程,并且参数通过寄存器从用户空间传递下来,通常会指定新的栈地址。

为了减少工作量采用写时复制技术(copy on write,COW),子进程只复制父进程的页表,不会复制页面内容,也就是说子进程和父进程的物理地址相同,虚拟地址不同。当子进程需要写入新内容时才触发写时复制机制,为子进程创建一个副本,此时,物理地址和虚拟地址都不同。

fork()实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符.

退出线程

进程退出分两种情况:进程主动退出终止进程

主动退出

(1)exit()用来线程退出。

(2)exit_group()用来使一个线程组的所有线程退出。

终止进程:

终止进程是通过给进程发送信号实现的,Linux内核提供了发送信号的系统调用。

(1)kill用来发送信号给进程或者进程组;

(2)tgkill用来发送信号给线程,参数tgid是线程组标识符,参数tid是线程标识符。

当进程退出的时候,根据父进程是否关注子进程退出事件,处理存在如下差异。

(1)如果父进程关注子进程退出事件,那么进程退出时释放各种资源,只留下一个空的进程描述符,变成僵尸进程,发送信号SIGCHLD通知父进程,父进程在查询进程终止(调用waitwaitpid)的原因以后回收子进程的进程描述符。

(2)如果父进程不关注子进程退出事件,那么进程退出时释放各种资源,释放进程描述符,自动消失。

Linux内核提供了2个系统调用来等待子进程的状态改变,状态改变包括:子进程终止,信号SIGSTOP使子进程停止执行,或者信号SIGCONT使子进程继续执行。

1pid_t waitpid(pid_t pid, int *wstatus, int options);
2int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

总结来说,父进程可以通过wait()waitpid()查询子进程是否终结,这其实使得父进程拥有了等待子进程执行完毕的能力。子进程退出执行后被设置为僵死状态,此时仍保留子进程的进程描述符,直到父进程调用wait()waitpid()后,才会回收子进程的进程描述符。

改动sample1来进行验证下:

#include<sys/types.h> 
#include<unistd.h> 
#include<stdio.h> 
#include<stdlib.h> 
#include<errno.h> 

int main() 

  printf("this is fork sample %dn",getpid());
  pid_t pid;/*pid 进程id号*/ 

  pid=fork();/*创建一个新进程*/ 
  printf("pid:%dn",pid);
  if(pid==0/*返回0为子进程*/ 
  { 
   printf("Return pid is %dn",pid); 
   printf("This is son process!  pid is:%dn",getpid()); 

  } 
  else if(pid>0)/*返回大于0为父进程*/ 
  { 
    printf("Return pid is %dn",pid); 
    printf("This is parent process!  pid is:%dn",getpid()); 
   // waitpid(pid,NULL,0);/*等待子进程退出*/
   while(1);
  } 
  else 
  { 
     perror("fork() error!"); 
     exit
  } 
}



Linux内核设计与实现——线程


可以看到184的父进程是183。由于父进程没有等待子进程结束,所以184目前处于僵死状态

子进程退出以后需要父进程回收进程描述符,如果父进程先退出,子进程成为“孤儿”,谁来为子进程回收进程描述符呢?父进程退出时需要给子进程寻找一个“领养者”,按照下面的顺序选择领养“孤儿”的进程。

(1)如果进程属于一个线程组,且该线程组还有其他线程,那么选择任意一个线程。

(2)选择最亲近的充当“替补领养者”的祖先进程。进程可以使用系统调用prctl(PR_SET_CHILD_SUBREAPER)把自己设置为“替补领养者”(subreaper)。

(3)选择进程所属的进程号命名空间中的1号进程。

这一章终于写完了, 不得不说Linux的内容太多了,本章的描写也参考了很多资料,导致写得特别慢,不过会坚持更新下去的。


原文始发于微信公众号(TreeNewBeer):Linux内核设计与实现——线程

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/115090.html

(1)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!