【无标题】

                                                 线程的生命周期

                                                                                    ——linux代码阅读报告

                     卢楷悦 20331027  林枫扬 20331024 刘嘉俊 20331025

Ps:以下代码均选自linux v5.16.16

       该报告将以线程的创建、运行、阻塞、死亡等一系列生命周期为线索,为状态间切换列举一或多个代码实例,从而简单介绍线程是什么,其如何运行。

一、线程的结构

线程的结构包括与其他结构体结合的辅助数据、标明线程身份的编号、状态信息、通信信息、命名空间信息等(部分结构未能理解),具体如下:

摘自/tools/perf/util/thread.h

二、线程的生命周期

该图囊括了线程的生命周期:就绪,运行,等待(深度睡眠、浅度睡眠)、僵尸与死亡等状态,以及创建,调度,阻塞,唤醒,退出等动作,接下来对其一一介绍。

  1. 创建

代码主要摘自/tools/perf/util/thread.c

       创建线程采用thread_new的函数,传入进程与线程id。该函数可建立一个没有任何信息的线程结构。

      

       线程创建的过程中,会对线程数据一一初始化,在使用zalloc分配空间的过程中,如果空间不够会跳转至err_thread,直接释放已分配空间,返回NULL

而INIT_LIST_HEAD函数是对list进行初始化,将节点的pre与next指针指向自身。

       init_rwsem则是利用c语言自身的函数对锁初始化。

       其后的comm_new函数是对通信的创建,需要利用到comm_str,创建完后,便可把comm_str释放了

srccode_state_init是对srccode_state的初始化,但小组尚未能理解该变量含义,在此简单列出其初始函数。

好了,现在一个没有任何信息的干净线程创建完毕。

2. 调度

主动调度: 是指由于等待某种资源,将进程的状态改成非RUNNING状态后,如TASK_INTERRUPTIBLE等,就调用schedule()主动让出CPU资源

抢占调度: 任务状态仍为RUNNING状态,当时却失去了CPU使用权,如我们比较常见的任务时间片用完,有更高优先级的任务等需要让出CPU资源

从头文件我们可以看到一些和调度相关的函数,也是我们接下来可能涉及的函数

2.1主动调度和抢占调度

在介绍调度前,我觉得有必要先简单介绍一下计时器,即操作系统的用的统一计时单元timer

Timer_counter, 时间计时器,其数据结构如下:

操作系统的一系列调度都是基于计时器指示的时间进行调度的,接下来我们先从调度的接口出发找到真正的内核代码

第一个接口schedule是最普通调度方法,简单的将当前调度该函数的线程做一系列处理后加入wq(等待队列)中等待接下来的唤醒,我们可以来看一下他加入队列前的准备函数

其中task的指示状态我们在这里列出几个具体不再细看

其中调用__schedule()前是需要关闭抢占的,

实际上在__ schedule中会去检查当前进程的抢占计数(位于schedule_debug函数),确保此次调度是在关闭抢占的情况下进行的,且不能是在中断或原子上下文发生的调用。内核的抢占本身也是为了执行调度,现在本身就已经在调度了,如果不关抢占,递归地执行进程调度怎么看都是一件没必要的事。在调度过程完成之后,也就是 __schedule 返回之后,这个过程中可能会被设置抢占标志,这时候还是需要重新执行调度的,schedulelinux调度器中最重要的一个函数,就像fork函数,它没有参数,没有返回值,却是实现内核中非常重要的功能,当需要执行调度时,直接调用schedule(),当前进程就停止了,而另外一个新进程占据了CPU。但是schedule() 函数只是个外层的封装,实际调用的还是 __ schedule() 函数, __ schedule() 接受一个参数,该参数为 bool 型,false 表示非抢占,自愿调度,而 true 则相反.其实最终是通过__schedule完成正在的调度工作,其定义在kernel/sched/core.c中,实现如下:

在他的代码注释上有简单介绍进入schedule的场景:显式阻塞场景、在中断和用户空间返回路径上检查TIF_NEED_RESCHED标志等(由于和生命周期无关,我也看不懂所以简单提一嘴)

从住宿我们需要知道是唤醒并不会真正导致schedule()的进入,他们添加一个任务到运行队列,仅此而已

接下来简单讲一下schedule用的计时函数scheduler_tick(),以用来为时间片轮转等策略提供从线程执行等状态到现在所经过的时间,我这里也不放代码了,简单讲一下过程

1)首先调用account_process_tick(),它根据当前进程运行了多久时间和当前进程类别,选择调用account_user_time()、account_system_time(),还是account_idle_time()。

2)调用run_local_timers(),获取本地时间

3)启动周期性定时器(scheduler_tick)完成该cpu上任务的周期性调度工作

4)调用sched_clock_tick()以纳秒为单位将当前时间放入sched_clock_data中

5)调用update_rq_clock()就绪队列时钟的更新, 实际上更新struct rq当前实例的时钟时间戳

6)调用curr->sched_class->task_tick()内核先找到了就绪队列上当前运行的进程curr, 然后调用curr所属调度类sched_class的周期性调度方法task_tick。

7)调用update_cpu_load()更新CPU上负载,为下一步执行调度类挑选进程做准备

接下来再简单讲一下调度策略(由于不是主要内容,只看了基于FIFO的时间片轮转)

schedule 就是主调度器的工作函数, 在内核中的许多地方, 如果要将CPU分配给与当前活动进程不同的另一个进程, 都会直接调用主调度器函数 schedule 或者其子函数 __schedule.其主要的流程如下:

1)完成一些必要的检查, 并设置进程状态, 处理进程所在的就绪队列

2)调度全局的pick_next_task选择抢占的进程

3)如果当前cpu上所有的进程都是cfs调度的普通非实时进程, 则直接用cfs调度, 如果无程序可调度则调度idle进程否则从优先级最高的调度器类 sched_class_highest(目前是stop_sched_class)开始依次遍历所有调度器类的 pick_next_task 函数, 选择最优的那个进程执行

4)context_switch 完成进程上下文切换

5)调用switch_mm(), 把虚拟内存从一个进程映射切换到新进程中

6)调用switch_to(),从上一个进程的处理器状态切换到新进程的处理器状态。这包括保存、恢复栈信息和寄存器信息

2.2调度延迟处理

   对外接口是sleep、msleep等,主要就是等待一段时间再重新加入等待队列

   当有些线程需要对自己阻塞或等待很久的程序进行处理时,比如说socket的超时处理

就需要立即对该线程调用schedule马重新加入ready队列

具体实现如下:

其中通过schedule把当前进程调度出cpu的运行队列,直到计时器超时(计时器其实也被加入了一个单独的不断运行的线程中)后调用wake_up_process()重新唤醒该线程,而schedule返回后,说明要不就是定时器到期,要不就是因为其它时间导致进程被唤醒,函数要做的就是删除在堆栈上建立的定时器,返回剩余未完成的jiffies数。

3. 阻塞

等待队列是一种基于资源状态的线程管理的机制,以双循环链表为基础数据结构,它可以使用在资源不满足的情况下处于休眠状态,而资源状态满足时唤醒线程,因此阻塞和唤醒的目的都是为了更好的利用cpu资源,且都围绕等待队列来展开。

①关键的数据结构体wait_queue_head和wait_queue_entry,其中func用于唤醒等待队列。Init_wait_event用于初始化。注意这里的func初始化为autoremove_wake_function,后面将介绍唤醒其实通过调用该睡眠进程设置的func函数,并重新设置该睡眠进程为RUNNING。

(阻塞与唤醒部分的源码均摘自/include/linux/wait.h与wait.c)

②wait_event用于使当前线程进入休眠等待状态,且睡眠不可中断直至condition满足。Wq_head为等待队列,condition为事件等待的“a C expression”(条件表达式)。当condition不满足时进入__wait_event宏,__wait_event再调用___wait_event。

③___wait_event创建一个等待条目__wq_entry,并初始化;在for循环中调用prepare_to_wait_event将__wq_entry加入等待队列,并不断判断条件是否满足;当条件满足时跳出循环调用finish_wait将__wq_entry从等待队列中删除。

④init_wait_entry初始化等待队列元素__wq_entry,与当前线程进行关联,并根据exclusive参数初始化属性标志,定义func函数为autoremove_wake_function。

⑤prepare_to_wait_event首先使用自旋锁禁止中断,再判断是否为空,调用__add_wait_queue_entry_tail或者__add_wait_queue将新创建的等待队列条目放入等待队列链表中。

⑥finish_wait执行休眠后的扫尾工作,将当前线程的状态设置为TASK_RUNNING,并将前面创建的等待条目__wq_entry从等待队列中删除。

4. 唤醒

①wake_up与wait_event相对应,用于唤醒处于等待队列上的进程,调用__wake_up_common_lock函数。

②wq_head是等待队列;mode指定线程的状态,用于控制唤醒线程的条件;nr_exclusive表示将要唤醒的设置了WQ_FLAG_EXCLUSIVE标志的线程的数目。然后扫描链表,调用func(注册的进程唤醒函数,默认为default_wake_function)唤醒每一个进程,直至队列为空,或者没有更多的进程被唤醒,或者被唤醒的的独占进程数目已经达到规定数目。

③自此__wake_up介绍完毕,接下来回到一开始func初始化的autoremove_wake_function,其接着调用了default_wake_function函数,两者都是回调函数。唤醒进程是在try_to_wake_up(curr->private, mode, wake_flags)函数中具体执行的,过程较为复杂在此不做分析。

 

④阻塞和唤醒的总过程图(来自网上)

5. 退出

代码主要摘自/tools/perf/util/thread.c

       线程的销毁主要是调用thread_delete函数。

首先,RB_EMPTY_NODE这个宏是用来检测红黑树是否为空的;之后的操作是清空线程栈跟thread的maps所指向的内容。

       在此介绍一下,在阅读代码中常见的两个利用锁的函数,一个是写锁,另一个是解锁,通过对锁的操作,完成临界区代码的要求。而命名空间跟通信列表的清理就利用了这个操作。

       命名空间的释放还利用了一个list_for_each_entry_safe的宏函数。这个宏函数本质上是利用了for循环让pos遍历了遍历了整个head所在链表的所有元素,其中还调用了其他的宏函数,这里不在展示其代码。

       通过上锁,遍历删除,解锁的过程,该函数销毁了命名空间与通信列表。

       结尾的代码就是只有一两行的可以“望文生义”的宏或普通函数,将命名空间信息,srccode_state,以及命名空间和通信的锁删除了。唯一需要提一下的是thread__free_stitch_list,这个函数也是对lbr_stitch内的内容进行一一删除后,才能删除该指针,此处放出代码。

       最后,只需free线程本身,线程便销毁完毕了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值