内核进程切换实现分析
當我們在linux編寫用戶態程序時并不需要考慮進程間是如何切換的, 即使當我們編寫驅動程序時也只需調用一些阻塞接口來讓渡cpu. 但是cpu究竟是如何切換進程的, 在進程切換過程中需要做什么, 今天我們通過分析內核schedule()的實現來看下內核是如何完成進程切換的.
先看下幾個相關的數據結構:
1 struct thread_info { 2 ??? unsigned long flags; 3 ??? /** 4 ???? * 搶占標記, 為0可搶占, 大于0不能搶占, 小于0出錯 5 ???? * preempt_disable()/preempt_enable()會修改該值 6 ???? * 同時也是被搶占計數, preempt_count的結構可見include/linux/hardirq.h中描述 7 ???? * 最低字節為搶占計數, 第二字節為軟中斷計數, 16-25位(10位)為硬中斷計數 8 ???? * 26位為不可屏蔽中斷(NMI)標記, 27位為不可搶占標記 9 ???? * 針對preempt_count的判斷宏都在include/linux/hardirq.h中 10 ???? * 11 ??? **/ 12 ??? int preempt_count; 13 ??? //break地址限制 14 ??? mm_segment_t addr_limit; 15 ??? //task結構體 16 ??? struct task_struct *task; 17 ??? struct exec_domain *exec_domain; 18 ??? //線程所在cpu號, 通過raw_smp_processor_id()獲取 19 ??? __u32 cpu; 20 ??? //保存協處理器狀態, __switch_to()中修改 21 ??? __u32 cpu_domain; 22 ??? //保存寄存器狀態, __switch_to()假定cpu_context緊跟在cpu_domain之后 23 ??? struct cpu_context_save cpu_context; 24 ??? __u32 syscall; 25 ??? __u8 used_cp[16]; 26 ??? unsigned long tp_value; 27 #ifdef CONFIG_CRUNCH 28 ??? struct crunch_state crunchstate; 29 #endif 30 ??? union fp_state fpstate __attribute__((aligned(8))); 31 ??? union vfp_state vfpstate; 32 #ifdef CONFIG_ARM_THUMBEE 33 ??? unsigned long thumbee_state; 34 #endif 35 ??? struct restart_block restart_block; 36 }; 37 struct task_struct { 38 ??? //任務狀態, 0為可運行, -1為不可運行, 大于0為停止 39 ??? volatile long state; 40 ??? void *stack; 41 ??? //引用計數 42 ??? atomic_t usage; 43 ??? //進程標記狀態位, 本文用到的是TIF_NEED_RESCHED 44 ??? unsigned int flags; 45 ??? unsigned int ptrace; 46 #ifdef CONFIG_SMP 47 ??? struct llist_node wake_entry; 48 ??? //該進程在被調度到時是否在另一cpu上運行, 僅SMP芯片判斷 49 ??? //prepare_lock_switch中置位, finish_lock_switch中清零 50 ??? int on_cpu; 51 #endif 52 ??? //是否在運行隊列(runqueue)中 53 ??? int on_rq; 54 ??? int prio, static_prio, normal_prio; 55 ??? unsigned int rt_priority; 56 ??? const struct sched_class *sched_class; 57 ??? struct sched_entity se; 58 ??? struct sched_rt_entity rt; 59 #ifdef CONFIG_CGROUP_SCHED 60 ??? struct task_group *sched_task_group; 61 #endif 62 #ifdef CONFIG_PREEMPT_NOTIFIERS 63 ??? struct hlist_head preempt_notifiers; 64 #endif 65 ??? unsigned int policy; 66 ??? int nr_cpus_allowed; 67 ??? //cpu位圖, 表明task能在哪些cpu上運行, 系統調用sched_setaffinity會修改該值 68 ??? cpumask_t cpus_allowed; 69 #if defined(CONFIG_SCHEDSTATS) || defined(CONFIG_TASK_DELAY_ACCT) 70 ??? //該進程調度狀態, 記錄進程被調度到的時間信息, 在sched_info_arrive中修改 71 ??? struct sched_info sched_info; 72 #endif 73 ??? /** 74 ???? * mm為進程內存管理結構體, active_mm為當前使用的內存管理結構體 75 ???? * 內核線程沒有自己的內存空間(內核空間共有), 所以它的mm為空 76 ???? * 但內核線程仍需要一個內存管理結構體來管理內存(即active_mm的作用) 77 ???? * 進程則同時存在mm與active_mm, 且兩者相等(否則訪問用戶空間會出錯) 78 ???? * 79 ??? **/ 80 ??? struct mm_struct *mm, *active_mm; 81 ??? //進程上下文切換次數 82 ??? unsigned long nvcsw, nivcsw; 83 ??? ...... 84 };?
首先來分析調度管理的入口, 內核調度的通用接口是schedule()(defined in kernel/sched/core.c).
1 asmlinkage void __sched schedule(void) 2 { 3 ??? struct task_struct *tsk = current; 4 ??? sched_submit_work(tsk); 5 ??? __schedule(); 6 }?
current即get_current()(defined in include/asm-generic/current.h), 后者為current_thread_info()->task. current_thread_info()是內聯函數(defined in arch/arm/include/asm/thread_info.h): THREAD_SIZE大小為8K, 即內核假定線程棧向下8K對齊處為thread_info, 通過它索引task_struct.
1 static inline struct thread_info *current_thread_info(void) 2 { 3 ??? register unsigned long sp asm ("sp"); 4 ??? return (struct thread_info *)(sp & ~(THREAD_SIZE - 1)); 5 }?
獲取task后判斷當前task是否需要刷新IO隊列, 然后執行實際的調度函數__schedule().
__schedule()的注釋詳細指出了調度發生的時機:
1. 發生阻塞時, 比如互斥鎖, 信號量, 等待隊列等.
2. 當中斷或用戶態返回時檢查到TIF_NEED_RESCHED標記位. 為切換不同task, 調度器會在時間中斷scheduler_tick()中設置標記位.
3. 喚醒不會真正進入schedule(), 它們僅僅在運行隊列中增加一個task. 如果新增的task優先于當前的task那么喚醒函數將置位TIF_NEED_RESCHED, schedule()將最可能在以下情況執行:
如果內核是開啟搶占的(CONFIG_PREEMPT), 在系統調用或異常上下文執行preempt_enable()之后(最快可能在wake_up()中spin_unlock()之后); 在中斷上下文, 從中斷處理程序返回開啟搶占后.
如果內核未開啟搶占, 那么在cond_resched()調用, 直接調用schedule(), 從系統調用或異常返回到用戶空間時, 從異常處理程序返回到用戶空間時.
?
__schedule()中首先關閉該線程的搶占(current_thread_info->preempt_count自減), 獲取線程所在cpu(current_thread_info->cpu), 再獲取對應cpu的rq. 此處先看下rq的結構(defined in kernel/sched/sched.h). cpu_rq()(defined in kernel/sched/sched.h)是一個復雜的宏, 用于獲取對應cpu的rq結構. 來看下它的定義(以SMP芯片為例):
1 #define cpu_rq(cpu) (&per_cpu(runqueues, (cpu))) 2 #define per_cpu(var, cpu) (*SHIFT_PERCPU_PTR(&(var), per_cpu_offset(cpu)))?
SHIFT_PERCPU_PTR()是對特定平臺的宏, 一般平臺上即直接取地址加偏移. 偏移不一定是線性的, 所以用per_cpu_offset()宏獲取(其實質是個數組, 在setup_per_cpu_areas()中初始化). 再看下runqueues(defined in kernel/sched/core.c)又是如何定義的.
1 DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues); 2 #define DEFINE_PER_CPU_SHARED_ALIGNED(type, name) \ 3 ??? DEFINE_PER_CPU_SECTION(type, name, PER_CPU_SHARED_ALIGNED_SECTION) \ 4 ??? ____cacheline_aligned_in_smp 5 #define DEFINE_PER_CPU_SECTION(type, name, sec) \ 6 ??? __PCPU_ATTRS(sec) PER_CPU_DEF_ATTRIBUTES \ 7 ??? __typeof__(type) name?
這樣就都串起來了, runqueues是struct rq的數組, 各個cpu通過偏移獲取對應結構體地址.
開始調度前首先要獲取運行隊列rq的自旋鎖. 調度器算法是由pre_schedule(), put_prev_task(), pick_next_task()與post_schedule()實現的. 這幾個接口都是當前進程調度器類型結構體(sched_class)的回調. 關于調度器模型的分析以后有空分析, 此處先略過.
回到__schedule(), 再得到調度后的進程后先清除調度前的進程的調度標記, 判斷調度前后進程是否不同, 不同則執行上下文切換的工作, context_switch()(defined in kernel/sched/core.c)是為了恢復到調度后進程的環境, 包括TLB(內核線程僅訪問內核空間無需切換, 用戶進程需切換), 恢復寄存器與堆棧等.
?
context_switch()中最主要的兩個函數是switch_mm()與switch_to(), 前者切換mm與TLB后者切換寄存器與棧. switch_mm()以后有空詳述, 先看下switch_to(), 其調用的__switch_to()(defined in arch/arm/kernel/entry-armv.S)是匯編函數:
1 #define switch_to(prev,next,last) \ 2 do { \ 3 ??? last = __switch_to(prev,task_thread_info(prev), task_thread_info(next)); \ 4 } while (0) 5 ENTRY(__switch_to) 6 UNWIND(.fnstart) 7 UNWIND(.cantunwind) 8 ??? add ip, r1, #TI_CPU_SAVE 9 ??? ldr r3, [r2, #TI_TP_VALUE] 10 ??? ARM( stmia ip!, {r4 - sl, fp, sp, lr} ) 11 ??? THUMB( stmia ip!, {r4 - sl, fp} ) 12 ??? THUMB( str sp, [ip], #4 ) 13 ??? THUMB( str lr, [ip], #4 ) 14 #ifdef CONFIG_CPU_USE_DOMAINS 15 ??? ldr r6, [r2, #TI_CPU_DOMAIN] 16 #endif 17 ??? set_tls r3, r4, r5 18 #if defined(CONFIG_CC_STACKPROTECTOR) && !defined(CONFIG_SMP) 19 ??? ldr r7, [r2, #TI_TASK] 20 ??? ldr r8, =__stack_chk_guard 21 ??? ldr r7, [r7, #TSK_STACK_CANARY] 22 #endif 23 #ifdef CONFIG_CPU_USE_DOMAINS 24 ??? mcr p15, 0, r6, c3, c0, 0 25 #endif 26 ??? mov r5, r0 27 ??? add r4, r2, #TI_CPU_SAVE 28 ??? ldr r0, =thread_notify_head 29 ??? mov r1, #THREAD_NOTIFY_SWITCH 30 ??? bl atomic_notifier_call_chain 31 #if defined(CONFIG_CC_STACKPROTECTOR) && !defined(CONFIG_SMP) 32 ??? str r7, [r8] 33 #endif 34 ??? THUMB( mov ip, r4 ) 35 ??? mov r0, r5 36 ??? ARM( ldmia r4, {r4 - sl, fp, sp, pc} ) 37 ??? THUMB( ldmia ip!, {r4 - sl, fp} ) 38 ??? THUMB( ldr sp, [ip], #4 ) 39 ??? THUMB( ldr pc, [ip] ) 40 UNWIND(.fnend) 41 ENDPROC(__switch_to)?
進入函數調用時R0與R1分別為調度前進程的task_struct與thread_info, R2為調度后進程的thread_info. 函數首先將當前寄存器中R4-R15(除IP與LR外)全部保存到調度前進程的thread_info.cpu_context(IP指向的地址)中, 然后恢復TLS(thread local store). set_tls(defined in rch/arm/include/asm/tls.h)用于內核向glibc傳遞TLS地址.
1 .macro set_tls_software, tp, tmp1, tmp2 2 ??? mov \tmp1, #0xffff0fff 3 ??? str \tp, [\tmp1, #-15] @ set TLS value at 0xffff0ff0 4 .endm?
在ARM_V7平臺上使用set_tls_software宏, 即將調度后進程的thread_info.tp_value(R3的值)保存在0xFFFF0FF0, 這是內核為glibc獲取TLS專門預留的地址.
恢復TLS后還需恢復協處理器, 同樣是從thread_info.cpu_domain(R6的值)中獲取. 然后調用回調通知鏈, 入參分別是thread_notify_head, THREAD_NOTIFY_SWITCH, 調度后進程的thread_info. 看了下這里注冊回調的都是與架構強相關的代碼: mm, fp, vfp和cp這幾類寄存器的修改. 因為與架構強相關, 先不分析了, 以后有空再看.
最后從調度后進程的thread_info.cpu_context(R4指向的地址)恢復寄存器. 想想為什么不一起保存/恢復R0-R3, IP和LR?
注意此處! 當PC被出棧后, 就切換到調度后進程代碼執行了, 即switch_to()是不返回的函數. 既然是不返回的函數, 后面的代碼是干什么的呢? 自然是線程恢復運行時的恢復代碼了. 當調度前的進程恢復(即通過__switch_to出棧PC恢復之前執行代碼地址)后繼續執行context_switch(). 此時進程身份已經互換了, 之前調度出去的進程作為被調度到的進程(但是代碼中的prev與next還是沒有改變, 因為寄存器與棧仍為之前的狀態), 而當前被調度出去的進程可能是之前調度到的進程, 也可能是第三個進程. 且此時程序運行在哪個CPU上也是不確定的. 所以先做內存屏障, 然后調用finish_task_switch()完成上下文切換.
?
至此完成進程上下文切換, 重新回到__schedule(), 執行post_schedule()完成清理工作, 恢復該進程可搶占狀態, 判斷當前線程是否需要調度, 如果需要再走一遍流程.
關于調度器的代碼我們將在以后具體分析, 如果感興趣也可以看下內核文檔中對調度器的說明(在Documentation/scheduler目錄下), 這里稍稍翻譯下(主要是針對cgroup使用的補充比較有價值).
1. sched-arch.txt
討論與架構相關的調度策略. 進程上下文切換中運行隊列的自旋鎖處理: 一般要求在握有rq->lock情況下調用switch_to. 在有些情況下(比如在進程上下文切換時有喚醒操作, 見arch/ia64/include/asm/system.h為例)switch_to需要獲取鎖, 此時調度器需要保證無鎖時調用switch_to. 在這種情況下需要定義__ARCH_WANT_UNLOCKED_CTXSW(一般與switch_to定義在一起).
2. sched-bwc.txt
討論SCHED_NORMAL策略的進程的帶寬控制. CFS帶寬控制需要配置CONFIG_FAIR_GROUP_SCHED. 帶寬控制允許進程組指定使用一個周期與占比, 對于給定的周期(以毫秒計算), 進程組最多允許使用占比長度的CPU時間, 當進程組執行超過其限制的時間進程將不得再執行直到下一個周期到來.
周期與占比通過CPU子系統cgroupfs管理. cpu.cfs_quota_us為一個周期內總的可運行時間(以毫秒計算), cpu.cfs_period_us為一個周期的長度(以毫秒計算), cpu.stat為調節策略. 默認值cpu.cfs_period_us=1000ms, cpu.cfs_period_us=-1. -1表明進程組沒有帶寬限制, 向其寫任何合法值將開啟帶寬限制, 最小的限制為1ms, 最大的限制為1s, 向其寫任何負數將取消帶寬限制并將進程組恢復到無約束狀態.
可以通過/proc/sys/kernel/sched_cfs_bandwidth_slice_us(默認5ms)獲取調度時間片長度.
進程組的帶寬策略可通過cpu.stat的3個成員獲取: nr_periods nr_throttled throttled_time.
存在兩種情況導致進程被節制獲取CPU: a. 它完全耗盡一個周期中的占比 b. 它的父進程完全耗盡一個周期中的占比. 出現情況b時, 盡管子進程存在運行時間但它仍不能獲取CPU直到它的父親的運行時間刷新.
3. sched-design-CFS.txt
CFS即completely fair scheduler, 自2.6.23后引入, 用于替換之前的SCHED_OTHER代碼. CFS設計目的是基于真實的硬件建立理想的, 精確的多任務CPU模型. 理想的多任務CPU即可以精確的按相同速度執行每一個任務, 比如在兩個任務的CPU上每個任務可以獲取一半性能. 真實的硬件中我們同時僅能運行一個任務, 所以我們引入虛擬運行時間的概念. 任務的虛擬運行時間表明在理想的多任務CPU上任務下一次執行時間, 實際應用中任務的虛擬運行時間即其真實的運行時間.
CFS中的虛擬運行時間通過跟蹤每個task的p->se.vruntime值, 借此它可以精確衡量每個task的期望CPU時間. CFS選擇task的邏輯是基于p->se.vruntime值: 它總是嘗試運行擁有最小值的task.
CFS設計上不使用傳統的runqueue, 而是使用基于時間的紅黑樹建立一個未來任務執行的時間線. 同時它也維護rq->cfs.min_vruntime值, 該值是單調增長的值, 用來跟蹤runqueue中最小的vruntime. runqueue中所有運行進程的總數通過rq->cgs.load值統計, 它是該runqueue上所有排隊的task的權重的綜合. CFS維護這一個時間排序的紅黑樹, 樹上所有可運行進程都以p->se.vruntime為鍵值排序, CFS選擇最左的task執行. 隨著系統持續運行, 執行過的task被放到樹的右側, 這給所有task一個機會成為最左的task并獲取CPU時間. 總結CFS工作流程: 當一個運行的進程執行調度或因時間片到達而被調度, 該任務的CPU使用值(p->se.vruntime)將加上它剛剛在CPU上消耗的時間. 當p->se.vruntime足夠高到另一個任務成為最左子樹的任務時(再加上一小段緩沖以保證不會發生頻繁的來回調度), 那么新的最左子樹的任務被選中.
CFS使用ns來計算, 它不依賴jiffies或HZ. 因此CFS沒有其它調度中有的時間片的概念. CFS只有一個可調整參數: /proc/sys/kernel/sched_min_granularity_ns用于調整工作負載(從桌面模式到服務器模式).
調度策略:
1. SCHED_NORMAL(傳統叫法SCHED_OTHER)用于普通task.
2. SCHED_BATCH不像通常task一樣經常發生搶占, 因此允許task運行更久, 更好利用cache.
3. SCHED_IDLE比nice值19更弱, 但它不是一個真正的idle時間調度器, 可以避免優先級反轉的問題.
SCHED_FIFO/SCHED_RR在sched/rt.c中實現并遵循POSIX規范.
調度器類型的實現通過sched_class結構, 它包含以下回調:
enqueue_task(): 當task進入可運行狀態時調用, 將task放入紅黑樹中并增加nr_running值.
dequeue_task(): 當task不再可運行時調用, 將task從紅黑樹中移除并減少nr_running值.
yield_task(): 將task移除后再加入紅黑樹.
check_preempt_curr(): 檢測進入可運行狀態的task是否可搶占當前運行的task.
pick_next_task(): 選擇最合適的task運行.
set_curr_task(): 當task改變調度器類型或改變任務組.
task_tick(): 通常由時間函數調用, 可能會導致進程切換, 這會導致運行時搶占.
通常情況下調度器針對單獨task操作, 但也可以針對任務組進行操作.
CONFIG_CGROUP_SCHED允許task分組并將CPU時間公平的分給這些組.
CONFIG_RT_GROUP_SCHED允許分組實時task.
CONFIG_FAIR_GROUP_SCHED允許分組CFS task.
以上選項需要定義CONFIG_CGROUPS, 并使用cgroup創建組進程, 具體見Documentation/cgroups/cgroups.txt.
舉例創建task組:
# mount -t tmpfs cgroup_root /sys/fs/cgroup
# mkdir /sys/fs/cgroup/cpu
# mount -t cgroup -ocpu none /sys/fs/cgroup/cpu
# cd /sys/fs/cgroup/cpu
# mkdir multimedia
# mkdir browser
# echo 2048 > multimedia/cpu.shares
# echo 1024 > browser/cpu.shares
# echo <firefox_pid> > browser/tasks
# echo <movie_player_pid> > multimedia/tasks
5. sched-nice-design.txt
由于舊版調度器nice值與時間片相關, 而時間片單位由HZ決定, 最小時間片為1/HZ. 在100HZ系統上nice值為19的進程僅占用1個jiffy. 但在1000HZ系統上其僅運行1ms, 導致系統頻繁的調度. 因此在1000HZ系統上nice值19的進程使用5ms時間片.
7.sched-stats.txt
查看調度器狀態: cat /proc/schedstat 參數太多, 不寫了.
查看每個進程調度狀態: cat /proc/<pid>/schedstat 分別為CPU占用時間, 在runqueue中等待時間, 獲取時間片次數.
?
轉載于:https://www.cnblogs.com/Five100Miles/p/8644993.html
總結
以上是生活随笔為你收集整理的内核进程切换实现分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Redis Cluster高可用(HA)
- 下一篇: 20165211 2017-2018-2