หัวข้อ 10 · 15 นาที
Thread context switch และการ implement thread
นึกภาพก่อน
พ่อครัวสองคนใช้เตาเดียว คนแรกทำค้างไว้แล้วต้องหลีกให้คนที่สอง ก่อนหลีกต้อง จดไว้ ว่าทำถึงขั้นไหน ไฟแรงเท่าใด ใส่อะไรไปแล้ว พอได้เตาคืนก็เปิดโน้ตแล้วทำต่อ
thread context switch คือสิ่งนี้: เก็บ register ของ thread เก่า แล้ว โหลด register ของ thread ใหม่ สิ่งที่ต้องจดมีแค่ค่าใน register เพราะ memory ที่เหลือ (code, heap, global) ใช้ร่วมกันอยู่แล้วและไม่หายไปไหน
แนวทางการ implement thread
| แนวทาง | ลักษณะ |
|---|---|
| Kernel threads | thread abstraction มีให้ใช้เฉพาะใน kernel — ในสายตา kernel นั้น kernel thread กับ user process ที่มี thread เดียวดูคล้ายกันมาก |
| Multithreaded processes ที่ใช้ kernel threads (Linux, MacOS) | operation ของ kernel thread เรียกได้ผ่าน system call |
| User-level threads | ทำ thread operation โดยไม่ใช้ system call |
การสร้าง thread: thread_fork
thread_fork(func, args) ทำตามลำดับ:
- จอง thread control block (TCB)
- จอง stack
- สร้าง stack frame สำหรับฐานของ stack (stub)
- วาง
funcและargsลงบน stack - ใส่ thread ลงใน ready list
- thread จะได้ทำงานในเวลาต่อมา (อาจจะทันทีเลยก็ได้!)
stub คือฟังก์ชันห่อที่อยู่ล่างสุดของ stack (ใน OS/161 ชื่อ mips_threadstart):
stub(func, args) {
(*func)(args); // เรียกฟังก์ชันของ thread
thread_exit(); // ถ้า func return ก็จบ thread ให้
}
ทำไมต้องมี stub: ถ้า func return โดยไม่มีอะไรรองรับ processor จะกระโดดไป address มั่ว ๆ ที่อยู่ล่าง stack
stub ทำให้ทุก thread จบด้วย thread_exit เสมอ ไม่ว่า func จะเรียกเองหรือไม่
ความละเอียดอ่อนข้อหนึ่ง
thread_create ใส่ thread ใหม่ลง ready list เมื่อมันได้ทำงานครั้งแรก จะมี thread อื่นเรียก switchframe ซึ่ง
เก็บ state ของ thread เก่าลง stack แล้ว restore state ของ thread ใหม่จาก stack แต่ thread ใหม่ยังไม่เคยทำงาน จึงไม่มี state ที่เคยเก็บไว้
ทางแก้: ตอนสร้าง ให้ จัด stack ของ thread ใหม่ให้เหมือนว่ามันเคยเก็บ state ไว้ใน switchframe โค้ด switch จึง "return" ไปที่ stub
ที่ฐานของ stack แล้ว stub ก็เรียก func โค้ด switch ไม่ต้องมีกรณีพิเศษสำหรับ thread ใหม่
ถ้า thread ใช้ stack มากเกินไป
สไลด์ตั้งคำถามว่าจะเกิดอะไรขึ้นถ้า thread เรียกฟังก์ชันซ้อนกันจน stack ล้น ใน Java, ใน Linux kernel, ใน OS/161 และควรจะเกิดอะไร
Thread context switch
| ชนิด | เกิดเมื่อ |
|---|---|
| Voluntary (สมัครใจ) | thread_yield หรือ thread_join (ถ้าลูกยังไม่จบ) |
| Involuntary (ไม่สมัครใจ) | interrupt หรือ exception หรือมี thread อื่นที่ priority สูงกว่า |
Voluntary context switch
- Save register ลงบน stack ของ thread เก่า
- สลับไปใช้ stack ใหม่ ของ thread ใหม่
- Restore register จาก stack ใหม่
- Return
ทำเหมือนกันทุกประการไม่ว่าจะเป็น kernel thread หรือ user thread
x86 switch_threads
# Save caller's register state
# NOTE: %eax, etc. are ephemeral
pushl %ebx
pushl %ebp
pushl %esi
pushl %edi
# Get offsetof (struct thread, stack)
mov thread_stack_ofs, %edx
# Save current stack pointer to old thread's stack, if any.
movl SWITCH_CUR(%esp), %eax
movl %esp, (%eax,%edx,1)
# Change stack pointer to new thread's stack
# this also changes currentThread
movl SWITCH_NEXT(%esp), %ecx
movl (%ecx,%edx,1), %esp
# Restore caller's register state.
popl %edi
popl %esi
popl %ebp
popl %ebx
ret
อ่านเป็นสี่ช่วงให้ตรงกับสี่ขั้นข้างบน:
| ช่วง | คำสั่ง | ทำอะไร |
|---|---|---|
| 1 | pushl สี่ตัว | save register ลง stack ของ thread เก่า |
| 2 | movl %esp, (%eax,%edx,1) | เก็บ stack pointer ปัจจุบันลง TCB ของ thread เก่า |
| 2 | movl (%ecx,%edx,1), %esp | โหลด stack pointer ของ thread ใหม่ — บรรทัดนี้คือจุดที่สลับ thread |
| 3 | popl สี่ตัว | restore register จาก stack ของ thread ใหม่ (ลำดับกลับกับตอน push) |
| 4 | ret | return ไปยังที่ที่ thread ใหม่เคยเรียก switch ไว้ |
ก่อนบรรทัดที่เปลี่ยน %esp เราอยู่บน stack ของ thread เก่า หลังบรรทัดนั้นเราอยู่บน stack ของ thread ใหม่
push กับ pop จึงทำกับคนละ stack ทั้งที่อยู่ในฟังก์ชันเดียวกัน %eax และ register ชั่วคราวอื่นไม่ต้องเก็บ เพราะเป็น ephemeral
(ผู้เรียกฟังก์ชันไม่คาดหวังว่าค่าจะคงอยู่)
Involuntary switch
เกิดจาก timer หรือ I/O interrupt ซึ่งบอก OS ว่าควรให้ thread อื่นทำงาน
แบบง่าย (OS/161)
- ตอนท้ายของ interrupt handler เรียก
switch() - เมื่อ thread ถูก resume การ return จาก handler จะ resume kernel thread หรือ user process
- ดังนั้น context ของ processor ถูก save/restore สองครั้ง: ครั้งหนึ่งโดย interrupt handler อีกครั้งโดย thread switch
แบบเร็วขึ้น
- interrupt handler เก็บ state ของ thread ที่ถูกขัดจังหวะ (ได้ trapframe)
- ตัดสินใจว่าจะให้ thread ใหม่ทำงาน
- ทิ้ง state ปัจจุบันของ interrupt handler ไปเลย
- ตั้ง stack pointer ที่เก็บไว้ให้ชี้ที่ trapframe แทน
- restore state ของ thread ใหม่
- เมื่อ thread เดิมถูก resume จะ pop trapframe เพื่อคืน state ของ thread ที่ถูกขัดจังหวะ
ประหยัดการ save หนึ่งรอบ เพราะ trapframe ที่ handler เก็บไว้ก็คือ state ครบชุดของ thread อยู่แล้ว
Multithreaded user process สามแบบ
| Take 1 | Take 2 | Take 3 | |
|---|---|---|---|
| ชื่อ | user thread = kernel thread | Green threads | Scheduler activations |
| ตัวอย่าง | Linux, MacOS | Java รุ่นแรก | Windows 8 |
| ใครทำ context switch | kernel | library ระดับ user ภายใน process ที่มี thread เดียว | thread library ระดับ user |
| thread operation | system call สำหรับ fork, join, exit (และ lock, unlock, …) | เรียกฟังก์ชันใน library ไม่ต้องเข้า kernel | library ตัดสินใจว่าจะให้ thread ใดทำงานถัดไป |
| preemption | kernel ทำ | ผ่าน upcall / UNIX signal เมื่อเกิด timer interrupt | kernel ทำ upcall เมื่อต้องการให้ user-level ตัดสินใจ |
| parallelism | ได้ | ต้องใช้หลาย process โดย map shared memory region เข้าแต่ละ process | kernel จัดสรร processor ให้ library |
| ข้อสังเกต | ง่าย แต่สลับ user/kernel mode บ่อย | เร็ว แต่ kernel มองเห็นเป็น thread เดียว | รวมข้อดีของสองแบบ |
Take 3: kernel ทำ upcall เมื่อ (1) process ได้รับ processor เพิ่ม (2) processor ถูกดึงออกจาก process (3) system call ไป block อยู่ใน kernel
ตัวอย่างไล่ทีละขั้น
โจทย์: thread A เรียก thread_yield() และ thread B อยู่หัว ready list ไล่สิ่งที่เกิด
- A เรียก
thread_yield→ เลือก B เป็น thread ถัดไป → ย้าย A ไปท้าย ready list (สถานะ Running → Ready) - เรียก
switch_threads(A, B) pushlสี่ตัว: register ของ A ถูกวางบน stack ของ A- เก็บ
%espลง TCB ของ A - โหลด
%espจาก TCB ของ B → ตอนนี้ทำงานบน stack ของ B poplสี่ตัว: ได้ register ที่ B เคย push ไว้ตอนที่ B ถูกสลับออกครั้งก่อนret: กลับไปยังจุดใน B ที่เคยเรียกswitch_threads→ B ทำต่อ (สถานะ Ready → Running)
ในมุมของ B มันแค่เรียก switch_threads แล้วฟังก์ชันนั้น return ตามปกติ แค่ใช้เวลานานหน่อย
จุดที่มักพลาด
1. คิดว่า thread_join เป็น voluntary switch เสมอ
เฉพาะเมื่อลูกยังไม่จบ ถ้าลูกจบแล้ว thread_join return ทันทีโดยไม่สลับ
2. คิดว่า context switch ต้อง copy stack
ไม่ต้อง แค่เปลี่ยนค่า stack pointer แต่ละ thread มี stack ของตัวเองอยู่แล้ว
3. คิดว่า pop ใน switch_threads ได้ค่าที่เพิ่ง push
ได้ค่าของ thread ใหม่ เพราะ %esp ถูกเปลี่ยนคั่นกลาง
4. สลับ green threads กับ kernel threads
green threads ทำ context switch ใน library ระดับ user ภายใน process ที่ kernel เห็นว่ามี thread เดียว
5. คิดว่า user-level thread ต้องใช้ system call ทุก operation
จุดขายของมันคือทำ thread operation ได้โดยไม่ต้องใช้ system call
ที่มา: 04-concurrency.pdf หน้า 15–28