Process và thread là nền tảng mà gần như mọi cơ chế user-mode khác được xây trên đó, nên có một mô hình tư duy rõ ràng về chúng sẽ trả lại lợi ích trong cả debug, tối ưu hiệu năng, và bảo mật. Windows cung cấp một API khá gọn gàng cho tất cả những thứ này, nhưng chủ đề vẫn mờ mịt với phần lớn mọi người - những phần thực sự thú vị (cấu trúc kernel, scheduler, các loại process kỳ lạ) thì tài liệu vừa thưa vừa rải rác.

Bài này là chuyến đi mà tôi từng mong có được. Thay vì kể lại API reference, nó dựng khái niệm lên từ đầu, chỉ ra cấu trúc kernel đằng sau từng thứ, và - phần mà hầu hết các bài viết bỏ qua - hướng bạn tới cách thấy tất cả những điều đó trên một hệ thống đang chạy bằng WinDbg hoặc Process Explorer. Chúng ta cũng sẽ xem API fiberjob nằm ở tầng trên, và cập nhật vài chi tiết (WSL2, Windows container) mà những bài viết cũ hơn chưa kịp có.


Quan hệ giữa Process và Thread

Dưới đây là bản tóm tắt về việc chúng nằm ở đâu và liên kết với nhau thế nào. Đây cũng là cái nhìn tổng quan về cách một số cấu trúc của process và thread móc nối vào nhau.

The components of a process

Giờ hãy đi vào process.

Process

Một ngộ nhận phổ biến là programprocess là cùng một thứ. Không phải. Program chỉ là một file nằm trên ổ đĩa chứa code. Còn process là một cái hộp đang sống: nó chứa một hoặc nhiều thread cùng toàn bộ tài nguyên mà những thread đó cần để chạy.

Tài nguyên của một Process

Một process thực sự sở hữu những gì? Tập hợp chính xác thì khác nhau, nhưng gần như mọi process đều mang cùng những phần cốt lõi sau.

  • Process Identifier (PID) - Một con số duy nhất định danh process trên hệ thống. Hai process có thể trùng tên, nhưng không thể có hai process cùng PID tại cùng một thời điểm.

  • Không gian địa chỉ ảo riêng - Một khoảng địa chỉ ảo được dành riêng cho process sử dụng. Kích thước khác nhau tuỳ hệ thống và cấu hình.

  • Executable Code - Code được map vào không gian địa chỉ của process từ program tương ứng. Thú vị là một process có thể tồn tại mà không có code thực thi nào - Windows dùng loại process đó cho vài mục đích đặc biệt.

  • Handle Table - Chứa con trỏ tới các kernel object mà process đang dùng. Những giá trị handle mà API trả về cho bạn thực chất chỉ là chỉ số trong bảng này. Nó nằm ở kernel mode, không thể chạm trực tiếp từ user mode - và nó chỉ theo dõi kernel object, không bao gồm các loại như GDI hay user object.

  • Access Token - Mỗi process mang một access token mô tả ngữ cảnh bảo mật của nó: thuộc user nào, có những privilege gì, v.v.

  • Process Environment Block (PEB) - Một cấu trúc user-mode, mỗi process một cái, chứa đầy thông tin về process - tham số dòng lệnh, có đang bị debug hay không, danh sách module đã nạp, và nhiều nữa. Microsoft không tài liệu hoá nó, nhưng nó trông như thế này:

 1struct _PEB {
 2    0x000 BYTE InheritedAddressSpace;
 3    0x001 BYTE ReadImageFileExecOptions;
 4    0x002 BYTE BeingDebugged;
 5    0x003 BYTE SpareBool;
 6    0x004 void* Mutant;
 7    0x008 void* ImageBaseAddress;
 8    0x00c _PEB_LDR_DATA* Ldr;
 9    0x010 _RTL_USER_PROCESS_PARAMETERS* ProcessParameters;
10    0x014 void* SubSystemData;
11    0x018 void* ProcessHeap;
12    0x01c _RTL_CRITICAL_SECTION* FastPebLock;
13    0x020 void* FastPebLockRoutine;
14    0x024 void* FastPebUnlockRoutine;
15    0x028 DWORD EnvironmentUpdateCount;
16    0x02c void* KernelCallbackTable;
17    0x030 DWORD SystemReserved[1];
18    0x034 DWORD ExecuteOptions:2;
19    0x034 DWORD SpareBits:30;
20    0x038 _PEB_FREE_BLOCK* FreeList;
21    0x03c DWORD TlsExpansionCounter;
22    0x040 void* TlsBitmap;
23    0x044 DWORD TlsBitmapBits[2];
24    0x04c void* ReadOnlySharedMemoryBase;
25    0x050 void* ReadOnlySharedMemoryHeap;
26    0x054 void** ReadOnlyStaticServerData;
27    0x058 void* AnsiCodePageData;
28    0x05c void* OemCodePageData;
29    0x060 void* UnicodeCaseTableData;
30    0x064 DWORD NumberOfProcessors;
31    0x068 DWORD NtGlobalFlag;
32    0x070 _LARGE_INTEGER CriticalSectionTimeout;
33    0x078 DWORD HeapSegmentReserve;
34    0x07c DWORD HeapSegmentCommit;
35    0x080 DWORD HeapDeCommitTotalFreeThreshold;
36    0x084 DWORD HeapDeCommitFreeBlockThreshold;
37    0x088 DWORD NumberOfHeaps;
38    0x08c DWORD MaximumNumberOfHeaps;
39    0x090 void** ProcessHeaps;
40    0x094 void* GdiSharedHandleTable;
41    0x098 void* ProcessStarterHelper;
42    0x09c DWORD GdiDCAttributeList;
43    0x0a0 void* LoaderLock;
44    0x0a4 DWORD OSMajorVersion;
45    0x0a8 DWORD OSMinorVersion;
46    0x0ac WORD OSBuildNumber;
47    0x0ae WORD OSCSDVersion;
48    0x0b0 DWORD OSPlatformId;
49    0x0b4 DWORD ImageSubsystem;
50    0x0b8 DWORD ImageSubsystemMajorVersion;
51    0x0bc DWORD ImageSubsystemMinorVersion;
52    0x0c0 DWORD ImageProcessAffinityMask;
53    0x0c4 DWORD GdiHandleBuffer[34];
54    0x14c void (*PostProcessInitRoutine)();
55    0x150 void* TlsExpansionBitmap;
56    0x154 DWORD TlsExpansionBitmapBits[32];
57    0x1d4 DWORD SessionId;
58    0x1d8 _ULARGE_INTEGER AppCompatFlags;
59    0x1e0 _ULARGE_INTEGER AppCompatFlagsUser;
60    0x1e8 void* pShimData;
61    0x1ec void* AppCompatInfo;
62    0x1f0 _UNICODE_STRING CSDVersion;
63    0x1f8 void* ActivationContextData;
64    0x1fc void* ProcessAssemblyStorageMap;
65    0x200 void* SystemDefaultActivationContextData;
66    0x204 void* SystemAssemblyStorageMap;
67    0x208 DWORD MinimumStackCommit;
68};
  • Thread - Thực thể bên trong process thực sự chạy code. Mọi process đều bắt đầu với ít nhất một thread - primary thread. Một process không có thread nào về lý thuyết vẫn tồn tại được, nhưng chẳng có gì thực thi, nên không hữu dụng lắm.

  • Cấu trúc EPROCESS - Cách kernel tự biểu diễn một process object. Nó khổng lồ - chứa gần như mọi thứ kernel biết về một process. Microsoft không tài liệu hoá. Ta sẽ mổ nó ở phần dưới.

  • Cấu trúc KPROCESS - Lồng bên trong EPROCESS, nó mang các chi tiết liên quan tới scheduling và memory: con trỏ tới page directory của process, thời gian CPU đã tiêu ở user mode so với kernel mode, affinity, và nhiều nữa. Cũng không được tài liệu hoá. Một góc nhìn đã cắt gọn:

 1struct _KPROCESS {
 2  struct _DISPATCHER_HEADER Header;
 3  struct _LIST_ENTRY ProfileListHead;
 4  unsigned int DirectoryTableBase;
 5  unsigned long Asid;
 6  struct _LIST_ENTRY ThreadListHead;
 7  unsigned long ProcessLock;
 8  unsigned long Spare0;
 9  unsigned int DeepFreezeStartTime;
10  struct _KAFFINITY_EX Affinity;
11  struct _LIST_ENTRY ReadyListHead;
12  struct _SINGLE_LIST_ENTRY SwapListEntry;
13  struct _KAFFINITY_EX ActiveProcessors;
14  long AutoAlignment : 1;
15  long DisableBoost : 1;
16  long DisableQuantum : 1;
17  unsigned long DeepFreeze : 1;
18  unsigned long TimerVirtualization : 1;
19  unsigned long CheckStackExtents : 1;
20  unsigned long SpareFlags0 : 2;
21  unsigned long ActiveGroupsMask : 20;
22  long ReservedFlags : 4;
23  long ProcessFlags;
24  char BasePriority;
25  char QuantumReset;
26  unsigned int Visited;
27  union _KEXECUTE_OPTIONS Flags;
28  unsigned long ThreadSeed[20];
29  unsigned int IdealNode[20];
30  unsigned int IdealGlobalNode;
31  union _KSTACK_COUNT StackCount;
32  struct _LIST_ENTRY ProcessListEntry;
33  unsigned int CycleTime;
34  unsigned int ContextSwitches;
35  struct _KSCHEDULING_GROUP *SchedulingGroup;
36  unsigned long FreezeCount;
37  unsigned long KernelTime;
38  unsigned long UserTime;
39  void *InstrumentationCallback;
40};

Sơ đồ dưới đây gom các phần lại - tài nguyên user-mode mà một process sở hữu, và các object kernel-mode EPROCESS/KPROCESS đứng sau nó.

The components of a process

Tự kiểm tra lấy

Mọi cấu trúc nói ở trên đều có bản đối chiếu sống mà bạn đọc trực tiếp được từ một hệ thống đang chạy. Cách làm như sau.

WinDbg (kernel debugger)

EPROCESS là bản ghi chính của kernel cho một process - tấm gương phía kernel của PEB ở user mode. Nhắm vào lsass.exe trong WinDbg là ta có ngay địa chỉ EPROCESS của nó:

0: kd> !process  0 0 lsass.exe
PROCESS ffffcf0514782180
	SessionId: 0  Cid: 02dc    Peb: c7661b7000  ParentCid: 024c
	DirBase: 10c761002  ObjectTable: ffff840f5f125d00  HandleCount: 1388.
	Image: lsass.exe

Từ đó ta dump được chính cấu trúc đó. Output đã cắt bớt, chỉ giữ những field quan trọng:

kd> dt nt!_eprocess ffffcf0514782180
   +0x000 Pcb              : _KPROCESS
    ...
   +0x4b8 Token            : _EX_FAST_REF
    ...
   +0x550 Peb              : 0x0000000f`a07b9000 _PEB
    ...
   +0x570 ObjectTable      : 0xffff840f`5f125d00 _HANDLE_TABLE

Ba field nổi bật. PcbKPROCESS lồng bên trong - nó giữ địa chỉ page-directory vật lý được nạp vào CR3 ở mỗi lần context switch, cùng dữ liệu scheduling: quantum, affinity, và base priority. Peb trỏ sang phía user-land, nối bản ghi kernel này về lại PEB. Còn ObjectTable là con trỏ tới Handle Table theo dõi mọi kernel object mà process đang giữ mở.

Nhìn vào bên trong KPROCESS lồng ở trong:

0: kd> dt nt!_KPROCESS ffffcf0514782180
   +0x000 Header           : _DISPATCHER_HEADER
   +0x018 ProfileListHead  : _LIST_ENTRY [ 0xffffcf05`14782198 - 0xffffcf05`14782198 ]
   +0x028 DirectoryTableBase : 0x00000001`0c761002
   +0x030 ThreadListHead   : _LIST_ENTRY [ 0xffffcf05`14779378 - 0xffffcf05`19c1d378 ]
   +0x040 ProcessLock      : 0
   +0x044 ProcessTimerDelay : 0
   +0x048 DeepFreezeStartTime : 0
   +0x050 Affinity         : _KAFFINITY_EX
   +0x0f8 AffinityPadding  : [12] 0
   +0x158 ReadyListHead    : _LIST_ENTRY [ 0xffffcf05`147822d8 - 0xffffcf05`147822d8 ]
   +0x168 SwapListEntry    : _SINGLE_LIST_ENTRY
   +0x170 ActiveProcessors : _KAFFINITY_EX
   +0x218 ActiveProcessorsPadding : [12] 0
    ...

Access Token

Token nằm ở EPROCESS.Token dưới dạng một con trỏ có tag. Phải mask bỏ các bit tag trước khi truyền cho !token:

0: kd> dt nt!_EPROCESS ffffcf0514782180 Token.Object
   +0x4b8 Token        : 
      +0x000 Object       : 0xffff840f`5f1ab9be Void

0: kd> !token @@C++(0x0xffff840f5f1ab9be & ~0xf)
Thread is not impersonating. Using process token...
_EPROCESS 0xffffcf0511067080, _TOKEN 0x0000000000000000
TS Session ID: 0
User: S-1-5-18
User Groups: 
 00 S-1-5-32-544
    Attributes - Default Enabled Owner 
 01 S-1-1-0
    Attributes - Mandatory Default Enabled 
 02 S-1-5-11
    Attributes - Mandatory Default Enabled 
 03 S-1-16-16384
    Attributes - GroupIntegrity GroupIntegrityEnabled 
Primary Group: S-1-5-18
Privs: 
 02 0x000000002 SeCreateTokenPrivilege            Attributes - 
 03 0x000000003 SeAssignPrimaryTokenPrivilege     Attributes - 
 04 0x000000004 SeLockMemoryPrivilege             Attributes - Enabled Default 
 05 0x000000005 SeIncreaseQuotaPrivilege          Attributes - 
 07 0x000000007 SeTcbPrivilege                    Attributes - Enabled Default 
 08 0x000000008 SeSecurityPrivilege               Attributes - 
 09 0x000000009 SeTakeOwnershipPrivilege          Attributes - 
 10 0x00000000a SeLoadDriverPrivilege             Attributes - 
 11 0x00000000b SeSystemProfilePrivilege          Attributes - Enabled Default 
 12 0x00000000c SeSystemtimePrivilege             Attributes - 
 13 0x00000000d SeProfileSingleProcessPrivilege   Attributes - Enabled Default 
 14 0x00000000e SeIncreaseBasePriorityPrivilege   Attributes - Enabled Default 
 15 0x00000000f SeCreatePagefilePrivilege         Attributes - Enabled Default 
 16 0x000000010 SeCreatePermanentPrivilege        Attributes - Enabled Default 
 17 0x000000011 SeBackupPrivilege                 Attributes - 
 18 0x000000012 SeRestorePrivilege                Attributes - 
 19 0x000000013 SeShutdownPrivilege               Attributes - 
 20 0x000000014 SeDebugPrivilege                  Attributes - Enabled Default 
 21 0x000000015 SeAuditPrivilege                  Attributes - Enabled Default 
 22 0x000000016 SeSystemEnvironmentPrivilege      Attributes - 
 23 0x000000017 SeChangeNotifyPrivilege           Attributes - Enabled Default 
 25 0x000000019 SeUndockPrivilege                 Attributes - 
 28 0x00000001c SeManageVolumePrivilege           Attributes - 
 29 0x00000001d SeImpersonatePrivilege            Attributes - Enabled Default 
 30 0x00000001e SeCreateGlobalPrivilege           Attributes - Enabled Default 
 31 0x00000001f SeTrustedCredManAccessPrivilege   Attributes - 
 32 0x000000020 SeRelabelPrivilege                Attributes - 
 33 0x000000021 SeIncreaseWorkingSetPrivilege     Attributes - Enabled Default 
 34 0x000000022 SeTimeZonePrivilege               Attributes - Enabled Default 
 35 0x000000023 SeCreateSymbolicLinkPrivilege     Attributes - Enabled Default 
 36 0x000000024 SeDelegateSessionUserImpersonatePrivilege  Attributes - Enabled Default 
Authentication ID:         (0,3e7)
Impersonation Level:       Anonymous
TokenType:                 Primary
Source: *SYSTEM*           TokenFlags: 0x2000 ( Token in use )
Token ID: 3eb              ParentToken ID: 0
Modified ID:               (0, 3ec)
RestrictedSidCount: 0      RestrictedSids: 0x0000000000000000
OriginatingLogonSession: 0
PackageSid: (null)
CapabilityCount: 0      Capabilities: 0x0000000000000000
LowboxNumberEntry: 0x0000000000000000
Security Attributes:
Invalid AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION with no claims
Process Token TrustLevelSid: S-1-19-1024-8192

Process Explorer (không cần debugger)

Không có debugger? Process Explorer hiển thị cùng dữ liệu đó qua GUI. Khung nhìn chính liệt kê mọi process trên hệ thống - tên, PID, CPU, và memory ngay trong một cái nhìn:

Process Explorer: Properties window showing Image, Threads, and Security tabs


Thread

Thread là thực thể thực sự chạy code trên CPU. Process cung cấp tài nguyên; thread làm việc. Không có thread thì process chẳng chạy được gì, và một process chứa nhiều thread cùng lúc được gọi là multi-threaded.

Thread Scheduling

Khi trên hệ thống có nhiều thread, scheduler chuyển đổi giữa chúng rất nhanh, tạo ra cảm giác rằng tất cả chạy cùng lúc. Thực tế scheduler chỉ đang nhảy qua nhảy lại giữa các thread nhanh đến mức trông như đồng thời. (Trên máy nhiều core thì một số thread thực sự chạy song song - mỗi logical core một thread - nhưng số thread hầu như luôn nhiều hơn số core rất nhiều, nên việc chuyển đổi vẫn là chuyện chính.)

Khoảng thời gian một thread được nhận trước khi scheduler có thể đẩy nó ra gọi là quantum, đo bằng tick đồng hồ của scheduler chứ không phải bằng giây thực. Quantum rất ngắn - vài millisecond một chữ số trên Windows bản client - đó là lý do hàng chục thread cho ta cảm giác đồng thời.

Priority. Windows là scheduler preemptive, dựa trên priority: nó luôn chạy thread có priority cao nhất đang ở trạng thái ready, và một thread priority cao hơn vừa trở thành ready sẽ chiếm chỗ thread thấp hơn ngay giữa quantum. Priority là một số từ 0 tới 31, kết hợp priority class của process (Idle, Below Normal, Normal, Above Normal, High, Realtime) với relative priority của thread trong class đó. Vài khoảng đáng biết:

  • 0 - dành riêng cho zero-page thread (xoá trắng bộ nhớ đã giải phóng ở chế độ nền); không gì khác chạy ở mức 0.
  • 1–15 - khoảng dynamic, gần như mọi thread bình thường sống ở đây.
  • 16–31 - khoảng real-time, nằm trên phần lớn hoạt động của hệ thống và cần privilege mới vào được. Một thread real-time hỏng hóc có thể làm cả hệ thống chết đói.

Priority boost. Để giữ hệ thống phản hồi tốt, Windows tạm thời đẩy priority lên khi: một thread hoàn tất I/O (để nó xử lý kết quả nhanh), các thread của cửa sổ đang ở foreground trở nên active (UI nhạy hơn), một thread thức lại sau khi chờ một event, hoặc một thread đã ready nhưng bị bỏ chờ quá lâu (chống chết đói). Boost giảm dần trở lại một mức mỗi quantum.

Thread state. Ở mỗi thời điểm một thread nằm ở một trong vài trạng thái - scheduling thực chất chỉ là việc kernel di chuyển thread giữa các trạng thái đó:

The thread state machine used by the Windows scheduler

Đường đi phổ biến là Ready → Standby → Running. Từ Running, một thread có thể bị đẩy về Ready, rơi vào Waiting trên một synchronization object hoặc I/O, hoặc kết thúc và thành Terminated. Standby nghĩa là “đã được chọn để chạy tiếp theo trên một processor cụ thể” - mỗi processor chỉ có đúng một thread ở trạng thái đó tại một thời điểm.

Tài nguyên của một Thread

Process cung cấp khá nhiều, nhưng mỗi thread vẫn cần vài thứ riêng của nó.

  • Context - Một cấu trúc riêng cho từng thread (do kernel quản lý) ghi lại trạng thái của toàn bộ register CPU tại lần cuối thread đó chạy. Điều này quan trọng vì mỗi CPU chỉ có thể có một thread chiếm giữ tại một thời điểm. Khi Windows chuyển thread, nó lưu trạng thái register hiện tại vào context của thread đang đi ra, và phục hồi lại khi thread đó được xếp lịch chạy lần nữa. Layout phụ thuộc processor - đây là bản x64:
 1typedef struct _CONTEXT {
 2  DWORD64 P1Home;
 3  DWORD64 P2Home;
 4  DWORD64 P3Home;
 5  DWORD64 P4Home;
 6  DWORD64 P5Home;
 7  DWORD64 P6Home;
 8  DWORD   ContextFlags;
 9  DWORD   MxCsr;
10  WORD    SegCs;
11  WORD    SegDs;
12  WORD    SegEs;
13  WORD    SegFs;
14  WORD    SegGs;
15  WORD    SegSs;
16  DWORD   EFlags;
17  DWORD64 Dr0;
18  DWORD64 Dr1;
19  DWORD64 Dr2;
20  DWORD64 Dr3;
21  DWORD64 Dr6;
22  DWORD64 Dr7;
23  DWORD64 Rax;
24  DWORD64 Rcx;
25  DWORD64 Rdx;
26  DWORD64 Rbx;
27  DWORD64 Rsp;
28  DWORD64 Rbp;
29  DWORD64 Rsi;
30  DWORD64 Rdi;
31  DWORD64 R8;
32  DWORD64 R9;
33  DWORD64 R10;
34  DWORD64 R11;
35  DWORD64 R12;
36  DWORD64 R13;
37  DWORD64 R14;
38  DWORD64 R15;
39  DWORD64 Rip;
40  union {
41    XMM_SAVE_AREA32 FltSave;
42    NEON128         Q[16];
43    ULONGLONG       D[32];
44    struct {
45      M128A Header[2];
46      M128A Legacy[8];
47      M128A Xmm0;
48      M128A Xmm1;
49      M128A Xmm2;
50      M128A Xmm3;
51      M128A Xmm4;
52      M128A Xmm5;
53      M128A Xmm6;
54      M128A Xmm7;
55      M128A Xmm8;
56      M128A Xmm9;
57      M128A Xmm10;
58      M128A Xmm11;
59      M128A Xmm12;
60      M128A Xmm13;
61      M128A Xmm14;
62      M128A Xmm15;
63    } DUMMYSTRUCTNAME;
64    DWORD           S[32];
65  } DUMMYUNIONNAME;
66  M128A   VectorRegister[26];
67  DWORD64 VectorControl;
68  DWORD64 DebugControl;
69  DWORD64 LastBranchToRip;
70  DWORD64 LastBranchFromRip;
71  DWORD64 LastExceptionToRip;
72  DWORD64 LastExceptionFromRip;
73} CONTEXT, *PCONTEXT;
  • Hai stack - Mỗi thread sở hữu một user-mode stack và một kernel-mode stack. User-mode stack lo phần việc thông thường như biến cục bộ. Kernel-mode stack không thể chạm tới từ user mode và đóng vai trò một ranh giới bảo mật: khi một thread thực hiện syscall, tham số của nó được copy từ user-mode stack sang kernel-mode stack. Một khi CPU đã chuyển sang kernel mode, kernel kiểm tra các tham số đó - và vì kernel stack không thể bị chạm từ user mode, thread không có cách nào can thiệp vào chúng sau khi đã kiểm tra. Sự tách biệt đó là một tính chất bảo mật then chốt.

  • Thread Local Storage (TLS) - Vùng lưu trữ riêng cho từng thread, dành cho dữ liệu thuộc về một thread và không nên chia sẻ với thread khác.

  • Thread ID (TID) - Mỗi thread có một TID duy nhất, đúng như mỗi process có một PID duy nhất.

  • Thread Environment Block (TEB) - Bản tương ứng ở mức thread của PEB: một cấu trúc giữ phần lớn trạng thái user-mode của một thread - con trỏ tới TLS của nó, LastErrorValue, con trỏ tới PEB, và nhiều nữa. LastErrorValue buộc phải riêng theo từng thread - nếu GetLastError của thread này thấy được mã lỗi của thread khác thì kết quả sẽ là hỗn loạn. TEB không được Microsoft tài liệu hoá.

  • Affinity - Đặt affinity cho một thread là ghim nó vào một CPU cụ thể. Đặt nó vào CPU 3 trên máy bốn core thì thread sẽ chỉ chạy trên CPU 3 cho tới khi nó kết thúc hoặc affinity thay đổi.

  • Cấu trúc ETHREAD / KTHREAD - ETHREAD (Executive Thread) là cách kernel biểu diễn một thread object - con trỏ tới PEB, LastErrorValue, đây có phải initial thread hay không, v.v. Lồng bên trong nó, KTHREAD (Kernel Thread) mang dữ liệu scheduling: con trỏ tới kernel stack, khi nào và bao lâu thread sẽ chạy, thời gian tiêu ở user mode, và nhiều nữa. Cả hai đều không được tài liệu hoá.

Đây là cách các thành phần của một thread ghép vào nhau, gồm cả cặp ETHREAD/KTHREAD ở kernel mode:

The components of a thread

Tự kiểm tra lấy

Phần bên trong của thread cũng quan sát được y như vậy. Một phiên kernel debugger nhắm vào cùng process lsass.exe cho ta toàn cảnh về thread.

WinDbg (kernel debugger)

Trước hết, xác nhận kích thước kernel stack mặc định - 24 KB trên hệ thống này:

0: kd> dx *(int*)&nt!KeKernelStackSize
*(int*)&nt!KeKernelStackSize : 24576 [Type: int]

Giờ tới các cấu trúc thread. Truy vấn lsass.exe với flag 0 2 sẽ liệt kê các thread của nó và lộ ra địa chỉ ETHREAD của từng cái:

0: kd> !process 0 2 lsass.exe 
PROCESS ffffcf0514782180
    SessionId: 0  Cid: 02dc    Peb: c7661b7000  ParentCid: 024c
    DirBase: 10c761002  ObjectTable: ffff840f5f125d00  HandleCount: 1388.
    Image: lsass.exe

        THREAD ffffcf0514779080  Cid 02dc.02ec  Teb: 000000c7661bc000 Win32Thread: 0000000000000000 WAIT: (WrLpcReceive) UserMode Non-Alertable
            ffffcf0514779508  Semaphore Limit 0x1

Truyền địa chỉ ETHREAD đó cho !thread sẽ cho một bức ảnh đầy đủ: lý do chờ, priority, số lần context switch, biên của kernel stack, và toàn bộ call stack tại thời điểm ta lấy mẫu.

0: kd> !thread ffffcf0514779080
THREAD ffffcf0514779080  Cid 02dc.02ec  Teb: 000000c7661bc000 Win32Thread: 0000000000000000 WAIT: (WrLpcReceive) UserMode Non-Alertable
    ffffcf0514779508  Semaphore Limit 0x1
Not impersonating
DeviceMap                 ffff840f5b635a20
Owning Process            ffffcf0514782180       Image:         lsass.exe
Attached Process          N/A            Image:         N/A
Wait Start TickCount      17775          Ticks: 22974 (0:00:05:58.968)
Context Switch Count      2              IdealProcessor: 3             
UserTime                  00:00:00.000
KernelTime                00:00:00.000
Win32 Start Address 0x00007ff7a99c20d0
Stack Init fffffe067d107dd0 Current fffffe067d107610
Base fffffe067d108000 Limit fffffe067d102000 Call 0000000000000000
Priority 10  BasePriority 9  IoPriority 2  PagePriority 5
Kernel stack not resident.
Child-SP          RetAddr               : Args to Child                                                           : Call Site
fffffe06`7d107650 fffff801`61cc7260     : ffffac00`ee1db180 00000000`ffffffff 00000000`00000000 00000000`00000000 : nt!KiSwapContext+0x76
fffffe06`7d107790 fffff801`61cc678f     : 00000000`00000003 00000000`00000001 fffffe06`7d107950 00000000`00000000 : nt!KiSwapThread+0x500
fffffe06`7d107840 fffff801`61cc6033     : 00000000`00000000 00000000`00000000 00000000`00000000 ffffcf05`147791c0 : nt!KiCommitThreadWait+0x14f
fffffe06`7d1078e0 fffff801`61d0e386     : ffffcf05`14779508 00000000`00000010 000004e8`fffffb01 000004d0`fffffb00 : nt!KeWaitForSingleObject+0x233
fffffe06`7d1079d0 fffff801`6207dcf8     : ffffffff`ffffffff 00000000`00000001 ffffcf05`1474ee20 00000000`00000000 : nt!AlpcpWaitForSingleObject+0x3e
fffffe06`7d107a10 fffff801`61fe8e25     : ffffffff`00000001 fffffe06`00000000 ffffcf05`1474ee20 ffffcf05`1474ee20 : nt!AlpcpCompleteDeferSignalRequestAndWait+0x3c
fffffe06`7d107a50 fffff801`61fea8bf     : 000000c7`6637f6a0 00000000`00000001 00000000`00000000 fffffe06`7d107af8 : nt!AlpcpReceiveMessagePort+0x265
fffffe06`7d107ac0 fffff801`61feab2b     : fffffe06`7d107ba0 000000c7`6637f6a0 00000000`00000000 00000000`00000000 : nt!AlpcpReceiveLegacyMessage+0x11f
fffffe06`7d107b60 fffff801`61feabcf     : ffffcf05`14779080 00000000`00000200 00000000`00000000 00000000`00000000 : nt!NtReplyWaitReceivePortEx+0xcb
fffffe06`7d107c00 fffff801`61e11505     : ffffcf05`14779080 00000000`00000000 00000000`00000000 ffffcf05`00000000 : nt!NtReplyWaitReceivePort+0xf
fffffe06`7d107c40 00007ffc`9f2ad704     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : nt!KiSystemServiceCopyEnd+0x25 (TrapFrame @ fffffe06`7d107c40)
000000c7`6637f478 00000000`00000000     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!NtReplyWaitReceivePort+0x14

ETHREADKTHREAD quan hệ với thread đúng như EPROCESSKPROCESS quan hệ với process - lần lượt là tầng Executive và tầng Kernel. Dump ETHREAD trực tiếp sẽ lộ ra các field thô:

0: kd> dt nt!_ETHREAD ffffcf0514782180
   +0x000 Tcb              : _KTHREAD
   +0x430 CreateTime       : _LARGE_INTEGER 0x0
   +0x438 ExitTime         : _LARGE_INTEGER 0x0
   +0x438 KeyedWaitChain   : _LIST_ENTRY [ 0x00000000`00000000 - 0x00000000`000002dc ]
   +0x448 PostBlockList    : _LIST_ENTRY [ 0xffffcf05`147dc788 - 0xffffcf05`147755c8 ]
   +0x448 ForwardLinkShadow : 0xffffcf05`147dc788 Void
   +0x450 StartAddress     : 0xffffcf05`147755c8 Void
   +0x458 TerminationPort  : (null) 
   +0x458 ReaperLink       : (null) 
   +0x458 KeyedWaitValue   : (null) 
   +0x460 ActiveTimerListLock : 0x144d0c01`0100d000
   +0x468 ActiveTimerListHead : _LIST_ENTRY [ 0x01dceea5`09511735 - 0x00000000`00005f70 ]
   +0x478 Cid              : _CLIENT_ID
   +0x488 KeyedWaitSemaphore : _KSEMAPHORE
   +0x488 AlpcWaitSemaphore : _KSEMAPHORE
   +0x4a8 ClientSecurity   : _PS_CLIENT_SECURITY_CONTEXT
   +0x4b0 IrpList          : _LIST_ENTRY [ 0xffffcf05`14617310 - 0xffff840f`5f1ab9be ]
   +0x4c0 TopLevelIrp      : 0
   +0x4c8 DeviceToVerify   : (null) 
   +0x4d0 Win32StartAddress : (null) 
   +0x4d8 ChargeOnlySession : (null) 
   +0x4e0 LegacyPowerObject : (null) 
   +0x4e8 ThreadListEntry  : _LIST_ENTRY [ 0x00000000`00000000 - 0x00000000`00000000 ]
   +0x4f8 RundownProtect   : _EX_RUNDOWN_REF
   +0x500 ThreadLock       : _EX_PUSH_LOCK
   +0x508 ReadClusterSize  : 0x806504a0
   +0x50c MmLockOrdering   : 0n-2799
   +0x510 CrossThreadFlags : 0
   +0x510 Terminated       : 0y0
   +0x510 ThreadInserted   : 0y0
   +0x510 HideFromDebugger : 0y0
   +0x510 ActiveImpersonationInfo : 0y0
   +0x510 HardErrorsAreDisabled : 0y0
    ...

KTHREAD lồng bên trong mang phần ruột của scheduling - con trỏ stack, cycle time, quantum, và toàn bộ trạng thái theo từng CPU mà scheduler chạm tới:

0: kd> dx -id 0,0,ffffcf0511067080 -r1 (*((ntkrnlmp!_KTHREAD *)0xffffcf0514782180))
(*((ntkrnlmp!_KTHREAD *)0xffffcf0514782180))                 [Type: _KTHREAD]
    [+0x000] Header           [Type: _DISPATCHER_HEADER]
    [+0x018] SListFaultAddress : 0xffffcf0514782198 [Type: void *]
    [+0x020] QuantumTarget    : 0xffffcf0514782198 [Type: unsigned __int64]
    [+0x028] InitialStack     : 0x10c761002 [Type: void *]
    [+0x030] StackLimit       : 0xffffcf0514779378 [Type: void *]
    [+0x038] StackBase        : 0xffffcf0519c1d378 [Type: void *]
    [+0x040] ThreadLock       : 0x0 [Type: unsigned __int64]
    [+0x048] CycleTime        : 0x0 [Type: unsigned __int64]
    [+0x050] CurrentRunTime   : 0x140001 [Type: unsigned long]
    [+0x054] ExpectedRunTime  : 0x0 [Type: unsigned long]
    [+0x058] KernelStack      : 0xf [Type: void *]
    [+0x060] StateSaveArea    : 0x0 [Type: _XSAVE_FORMAT *]
    [+0x068] SchedulingGroup  : 0x0 [Type: _KSCHEDULING_GROUP *]
    [+0x070] WaitRegister     [Type: _KWAIT_STATUS_REGISTER]
    [+0x071] Running          : 0x0 [Type: unsigned char]
    [+0x072] Alerted          [Type: unsigned char [2]]
    [+0x074 ( 0: 0)] AutoBoostActive  : 0x0 [Type: unsigned long]
    [+0x074 ( 1: 1)] ReadyTransition  : 0x0 [Type: unsigned long]
    [+0x074 ( 2: 2)] WaitNext         : 0x0 [Type: unsigned long]
    [+0x074 ( 3: 3)] SystemAffinityActive : 0x0 [Type: unsigned long]
    [+0x074 ( 4: 4)] Alertable        : 0x0 [Type: unsigned long]
    [+0x074 ( 5: 5)] UserStackWalkActive : 0x0 [Type: unsigned long]
    [+0x074 ( 6: 6)] ApcInterruptRequest : 0x0 [Type: unsigned long]
    [+0x074 ( 7: 7)] QuantumEndMigrate : 0x0 [Type: unsigned long]
    [+0x074 ( 8: 8)] UmsDirectedSwitchEnable : 0x0 [Type: unsigned long]
    [+0x074 ( 9: 9)] TimerActive      : 0x0 [Type: unsigned long]
    [+0x074 (10:10)] SystemThread     : 0x0 [Type: unsigned long]
    [+0x074 (11:11)] ProcessDetachActive : 0x0 [Type: unsigned long]
    [+0x074 (12:12)] CalloutActive    : 0x0 [Type: unsigned long]
    [+0x074 (13:13)] ScbReadyQueue    : 0x0 [Type: unsigned long]
    [+0x074 (14:14)] ApcQueueable     : 0x0 [Type: unsigned long]
    [+0x074 (15:15)] ReservedStackInUse : 0x0 [Type: unsigned long]
    [+0x074 (16:16)] UmsPerformingSyscall : 0x0 [Type: unsigned long]
    [+0x074 (17:17)] TimerSuspended   : 0x0 [Type: unsigned long]
    [+0x074 (18:18)] SuspendedWaitMode : 0x0 [Type: unsigned long]
    [+0x074 (19:19)] SuspendSchedulerApcWait : 0x0 [Type: unsigned long]
    [+0x074 (20:20)] CetUserShadowStack : 0x0 [Type: unsigned long]
    [+0x074 (21:21)] BypassProcessFreeze : 0x0 [Type: unsigned long]
    [+0x074 (31:22)] Reserved         : 0x0 [Type: unsigned long]
    ...

Từ user mode (không cần kernel debugger)

Trên x64, register segment GS luôn trỏ tới TEB của thread hiện tại, nên có thể đọc nó từ bất kỳ phiên WinDbg user-mode nào:

// In WinDbg user-mode session: read the TEB of the current thread
0:000> !teb
TEB at 00000025fac41000
    ExceptionList:        0000000000000000
    StackBase:            00000025faba0000
    StackLimit:           00000025fab8f000
    SubSystemTib:         0000000000000000
    FiberData:            0000000000001e00
    ArbitraryUserPointer: 0000000000000000
    Self:                 00000025fac41000
    EnvironmentPointer:   0000000000000000
    ClientId:             00000000000020a4 . 0000000000004c9c
    RpcHandle:            0000000000000000
    Tls Storage:          000001d3e8e49b00
    PEB Address:          00000025fac40000
    LastErrorValue:       2
    LastStatusValue:      c0000034
    Count Owned Locks:    0
    HardErrorMode:        0

Process Explorer

  • Properties → Threads - liệt kê mọi thread cùng TID, địa chỉ bắt đầu (đã phân giải symbol nếu PDB được nạp), thời gian CPU, và priority hiện tại.

Process Explorer: Threads tab showing TID, start address, CPU%, and priority for each thread

Dùng Thread

Tạo một thread thì đơn giản: gọi CreateThread với địa chỉ một hàm và thread mới sẽ bắt đầu thực thi ở đó. Hàm đó là entry point của thread - nó chạy ngay khi thread bắt đầu.

1HANDLE CreateThread(
2  [in, optional]  LPSECURITY_ATTRIBUTES   lpThreadAttributes,
3  [in]            SIZE_T                  dwStackSize,
4  [in]            LPTHREAD_START_ROUTINE  lpStartAddress,
5  [in, optional]  __drv_aliasesMem LPVOID lpParameter,
6  [in]            DWORD                   dwCreationFlags,
7  [out, optional] LPDWORD                 lpThreadId
8);
  • lpThreadAttributes - security attributes cho thread mới. Truyền NULL để lấy mặc định.
  • dwStackSize - kích thước stack ban đầu. Truyền 0 để lấy mặc định.
  • lpStartAddress - hàm entry point.
  • lpParameter - một giá trị tuỳ ý được truyền thẳng vào entry point.
  • dwCreationFlags - truyền 0 để chạy ngay, hoặc CREATE_SUSPENDED để bắt đầu ở trạng thái tạm dừng.
  • lpThreadId - nhận TID của thread mới. Truyền NULL nếu bạn không cần.

Một lưu ý về C runtime. CreateThread là lời gọi Win32 thô và hoạt động tốt nếu thread không bao giờ chạm tới C runtime (CRT). Nếu nó dùng hàm CRT, hãy chọn _beginthreadex - hàm này khởi tạo và dọn dẹp phần trạng thái CRT riêng của mỗi thread mà những hàm như strtok dựa vào. Bỏ qua nó có thể làm rò rỉ trạng thái đó một cách âm thầm. _beginthreadex gọi CreateThread ở bên trong, nên bạn không mất gì cả.

Chữ ký của entry point. Hàm bạn truyền vào phải khớp LPTHREAD_START_ROUTINE: DWORD WINAPI ThreadProc(LPVOID lpParameter). Viết đúng chữ ký giúp tránh phải cast và tránh undefined behavior do sai calling convention.

Fiber

Fiber là đơn vị thực thi mà bạn tự xếp lịch bằng tay, thay vì để kernel scheduler làm. Một thread có thể giữ nhiều fiber nhưng chỉ chạy một cái tại một thời điểm - và bạn chọn cái nào, một cách tường minh, bằng cách gọi SwitchToFiber. Vì chúng sống hoàn toàn trong user mode bên trong Kernel32.dll, kernel không thấy chúng chút nào.

Fiber còn đáng dùng không? Phần lớn là không. Fiber có trước các primitive async hiện đại và được coi là gần như legacy - chính Microsoft cũng ghi rằng chúng hiếm khi có lợi thế so với code multithread được thiết kế tốt. Chúng cũng tương tác tệ với C runtime và thread-local storage, vì các fiber dùng chung một thread cũng dùng chung slot TLS của thread đó (fiber có fiber-local storage riêng, FLS, qua FlsAlloc/FlsGetValue, chính là để lách chuyện này). Trừ khi bạn đang port code xây quanh một cooperative scheduler, hãy chọn thread, thread pool, hoặc async I/O. Chúng vẫn là một phương tiện rất tốt để hiểu cooperative scheduling, và đó là lý do ta nói về chúng ở đây.

Điểm phân biệt then chốt là ai làm việc xếp lịch. Kernel xếp lịch thread một cách preemptive; fiber được chuyển đổi một cách cooperative bởi chính code của bạn, và kernel còn không thấy được chúng:

Relationship between a process, its threads, and fibers

Dùng Fiber

Bắt đầu bằng cách chuyển thread hiện có của bạn thành một fiber với ConvertThreadToFiber:

1LPVOID ConvertThreadToFiber(
2  [in, optional] LPVOID lpParameter
3);

Hàm này trả về địa chỉ context của fiber mới - bạn sẽ cần nó cho SwitchToFiber về sau. Context của fiber tương tự context của thread nhưng còn giữ lpParameter, đỉnh và đáy stack của fiber, cùng vài thứ khác. Từ đây trở đi, thread của bạn chính là một fiber; nó kết thúc khi công việc của fiber xong hoặc khi nó gọi ExitThread (kết thúc cả fiber và thread).

Để tạo thêm fiber, gọi CreateFiber:

1LPVOID CreateFiber(
2  [in]           SIZE_T                dwStackSize,
3  [in]           LPFIBER_START_ROUTINE lpStartAddress,
4  [in, optional] LPVOID                lpParameter
5);
  • dwStackSize - kích thước stack của fiber; 0 dùng mặc định (nở tới 1 MB).
  • lpStartAddress - hàm chạy khi fiber được xếp lịch.
  • lpParameter - truyền thẳng vào hàm đó.

CreateFiber chỉ tạo fiber - nó không chạy fiber. Để thực sự chuyển sang nó, gọi:

1void SwitchToFiber(
2  [in] LPVOID lpFiber
3);

Truyền địa chỉ context của fiber mà CreateFiber trả về. Việc thực thi chuyển sang ngay lập tức. Khi xong, dọn dẹp bằng:

1void DeleteFiber(
2  [in] LPVOID lpFiber
3);

Process, thread và fiber trong một cái nhìn

ProcessThreadFiber
Là gìHộp chứa tài nguyênĐơn vị thực thi trên CPUĐơn vị thực thi cooperative
Ai xếp lịch- (chứa thread)Kernel, kiểu preemptiveBạn, bằng tay (SwitchToFiber)
Sở hữuKhông gian địa chỉ, handle, tokenStack, context, TLSMột stack + context đã lưu
Kernel thấy được?Có (EPROCESS)Có (ETHREAD)Không - thuần user-mode
Số lượng1+ mỗi hệ thống1+ mỗi process0+ mỗi thread, một cái chạy tại một thời điểm
Tạo bằngCreateProcessCreateThread / _beginthreadexCreateFiber + SwitchToFiber

Bên trong CreateProcess

Khi một thread muốn dựng lên một process mới, nó gọi CreateProcess và truyền tham số để định hình process mới theo ý mình. Hàm này linh hoạt đủ để phủ gần như mọi tình huống, nhưng đôi khi một mình nó chưa đủ - nên API có thêm một họ nhỏ các hàm bọc ngoài:

  • CreateProcessAsUser tạo một process thay mặt một user khác bằng cách nhận handle tới primary token của user đó.
  • CreateProcessWithTokenW làm việc tương tự nhưng đòi một tập privilege khác.
  • CreateProcessWithLogonW nhận thẳng credential (username, domain, password) thay vì handle của token.
  • ShellExecute là cái khác biệt nhất. Ba hàm trước làm việc với bất kỳ file PE hợp lệ nào bất kể phần mở rộng - bạn có thể đổi tên notepad.exe thành notepad.txt và chúng vẫn chạy được. ShellExecuteShellExecuteEx thì nhận biết loại file: chúng tra chương trình liên kết trong HKLM\SOFTWARE\ClassesHKCU\SOFTWARE\Classes, rồi cuối cùng gọi CreateProcess với đúng file thực thi và đường dẫn file làm tham số. Đưa cho nó một file .txt thì nó chạy notepad.exe filename.txt.

Cả CreateProcessCreateProcessAsUser đều được export bởi Kernel32.dll. Cả hai cuối cùng đều dồn vào CreateProcessInternal (cũng trong Kernel32.dll), rồi hàm này gọi NtCreateUserProcess trong ntdll.dll. NtCreateUserProcess là chặng cuối trong user mode - khi nó xong, nó thực hiện một syscall và vượt sang kernel mode. Cả CreateProcessInternalNtCreateUserProcess đều không được tài liệu hoá chính thức.

Còn CreateProcessWithTokenWCreateProcessWithLogonW thì được export bởi Advapi32.dll. Mỗi hàm thực hiện một lời gọi RPC tới Secondary Logon Service (seclogon.dll, chạy trong svchost.exe), service này lo phần tra credential rồi cuối cùng gọi CreateProcessAsUser thay bạn.

Đặt cả hai đường đi cạnh nhau:

How the CreateProcess family of functions calls down into the kernel

Tham số

CreateProcess nhận mười tham số, nhưng phần lớn là những cái núm kiểu “truyền NULL để lấy mặc định” mà MSDN đã tài liệu hoá cặn kẽ. Vài cái thực sự đáng hiểu:

  • lpApplicationName so với lpCommandLine - nguồn nhầm lẫn muôn thuở. Bạn có thể ghi tên file thực thi ở một trong hai. Điểm gài: nếu bạn để lpApplicationNameNULL và truyền một tên không đủ đường dẫn trong lpCommandLine, Windows sẽ tìm kiếm qua thư mục của app, các thư mục hệ thống, thư mục hiện tại, và PATH. Dấu cách trong một đường dẫn không đặt trong ngoặc kép là nguồn kinh điển của những bug kiểu “nó chạy sai chương trình”.
  • bInheritHandles - con có kế thừa những handle có thể kế thừa của cha hay không. Kết hợp với cờ bInheritHandle trong từng SECURITY_ATTRIBUTES, đây là cách một process cha chủ động chuyển một handle cụ thể (chẳng hạn một pipe) xuống cho process con.
  • dwCreationFlags - hai cái bạn sẽ dùng nhiều nhất: CREATE_SUSPENDED (primary thread bắt đầu ở trạng thái dừng; resume sau bằng ResumeThread - dùng ở khắp nơi từ debugger tới việc set up job) và DEBUG_PROCESS (bên gọi trở thành debugger của process mới).
  • lpStartupInfo - một STARTUPINFO/STARTUPINFOEX mô tả cửa sổ ban đầu của con, các std handle, và (ở dạng EX) một danh sách attribute cho các tuỳ chọn nâng cao như chọn process cha.
  • lpProcessInformation - phần output: PID mới, TID của primary thread, và handle tới cả hai. Bạn sở hữu hai handle đó và phải CloseHandle chúng, ngay cả khi bạn không dùng tới - một chỗ rò rỉ rất dễ bỏ sót vì chương trình vẫn chạy bình thường.

Các tham số về token/credential là khác biệt có ý nghĩa duy nhất giữa các thành viên trong họ hàm này. Còn lại thì giống nhau.

Chuyện gì xảy ra trước main()? CreateProcess trả về không có nghĩa là code của bạn đang chạy. Kernel map image, rồi loader (ntdll) trong process mới map mọi DLL phụ thuộc, chạy DllMain của chúng và các TLS callback, rồi CRT startup khởi tạo runtime - sau đó mới gọi entry point của bạn. Một lượng đáng ngạc nhiên các mánh của kẻ tấn công và của anti-cheat sống đúng trong khoảng thời gian giữa “process đã được tạo” và “câu lệnh đầu tiên của bạn”.


Phân loại Process

Windows định nghĩa vài loại process (phần lớn là) riêng biệt cho những trường hợp cần thêm bảo mật hoặc phục vụ mục đích đặc biệt. Chúng không được khởi chạy như process thường, và mang những thuộc tính khác.

Protected Process

Protected process ban đầu được đưa ra để thoả các yêu cầu DRM mà ngành công nghiệp nội dung đặt ra cho những thứ như HD-DVD.

Bình thường, bất kỳ process nào giữ debug privilege - thường là process được administrator khởi chạy - đều đọc và ghi được bộ nhớ của mọi process khác trên hệ thống. Điều đó hữu dụng trong nhiều tình huống, nhưng vi phạm trực tiếp yêu cầu DRM. Protected process giải quyết chuyện này: chúng cùng tồn tại với process thường nhưng cho bên ngoài rất ít hoặc không cho chút quyền truy cập nào, kể cả bên đang chạy dưới quyền administrator. Để thực sự chạy như một protected process, file thực thi phải được ký bằng Windows Media Certificate đặc biệt và chỉ nạp những DLL được ký tương tự; dữ liệu của nó chỉ kernel hoặc protected process khác chạm tới được. Trong thực tế bạn sẽ thấy điều này ở các process giải mã DRM audio/video (Audiodg.exe, Mfpmp.exe), ở protected crash-reporter (Werfaultsecure.exe - WER thường không đọc được bộ nhớ của một protected process đã crash), và ở chính process System.

Protected Processes Light (PPL)

PPL là dạng mở rộng của protected process, được tạo ra để cho phần mềm của bên thứ ba - ví dụ chương trình antivirus - có được sự bảo vệ tương tự. Điểm khác: mức độ bảo vệ của một PPL phụ thuộc vào signature level của nó, nên có PPL được bảo vệ nhiều hơn PPL khác. Nhiều process hệ thống cốt lõi của Windows được bảo vệ dạng PPL, gồm smss.exe, csrss.exe, và services.exe.

Minimal Process

Về bản chất đây là những process rỗng. Không gian địa chỉ user-mode của chúng trống trơn: không có ntdll.dll hay các DLL subsystem, không có PEB hay TEB, không có initial thread, không có image nào được map. Kernel tạo và quản lý chúng, và không có cách nào từ user mode để sinh ra một cái - chúng tồn tại cho những nhiệm vụ đặc biệt của chính hệ thống.

Minimal process có thể chứa minimal thread, loại thread không có TEB và không có stack.

Một ví dụ thực tế là process memory compression, nó giữ bộ nhớ đã nén của các process đang hoạt động để hệ thống có thể giữ được nhiều hơn trong RAM thay vì phải paging ra đĩa. Nó bị ẩn khỏi Task Manager vì working set của nó trông lớn đến mức đáng báo động (nó lưu bộ nhớ đã nén thuộc về các process khác), và người dùng từng thấy nó đáng nghi. Bạn có thể phát hiện nó trong Process Explorer bằng cách sắp xếp theo working set - nó sẽ nổi lên gần đầu. Nó không có thread nào và không có code riêng.

Pico Process

Pico process ra đời từ một nỗ lực nghiên cứu của Microsoft có tên Project Drawbridge. Một pico process là một minimal process ghép với một driver hỗ trợ gọi là pico provider. Provider có thể quản lý gần như mọi thứ về việc thực thi của pico process - đến mức nó có thể hành xử như một kernel riêng cho process đó, và process không hề biết hệ thống bên dưới thực sự là gì. Tuy vậy, quản lý bộ nhớ, I/O, và xếp lịch thread vẫn là việc của kernel Windows thật.

Một pico provider chặn mọi thao tác của pico process cần kernel xử lý - system call, exception, v.v. - và phản hồi theo cách nó thấy phù hợp. Pico process có thể chứa cả pico thread (minimal thread dành cho pico process) và thread thường. Pico thread mang một context lưu trong thành viên PicoContext của ETHREAD.

Windows Subsystem for Linux

WSL được xây trên mô hình pico process. Nó có thể chạy một hệ thống Linux gần như đầy đủ trên Windows mà không cần một dòng code nào của kernel Linux - điều này khả thi nhờ mức độ kiểm soát đáng kinh ngạc mà pico provider mang lại.

Pico provider của WSL là lxss.syslxcore.sys. Chúng mô phỏng hành vi của kernel Linux bằng cách dịch syscall Linux từ pico process của WSL sang NT API, hoặc bằng cách gọi các thành phần được viết riêng từ đầu.

WSL1 so với WSL2 - một lưu ý quan trọng. Mọi thứ ở trên mô tả WSL1, tầng dịch dựa trên pico process. WSL2, mặc định hiện nay, hoạt động hoàn toàn khác: nó chạy một kernel Linux thật bên trong một máy ảo Hyper-V nhẹ. WSL2 không có pico process - nó đạt được khả năng tương thích syscall đầy đủ và hiệu năng tốt hơn, đổi lại mất đi sự tích hợp liền mạch mà mô hình pico đem lại cho WSL1. Nếu hôm nay bạn đang xem các process Linux, gần như chắc chắn bạn đang nhìn vào kernel Linux thật của WSL2, không phải một pico provider.

Trustlet (Secure Process)

Trustlet chạy trong Isolated User Mode (IUM), được bảo vệ bởi Virtual Trust Level của hypervisor Hyper-V - code chạy trong OS bình thường (kể cả kernel) nằm ở trust level thấp hơn và không đọc được bộ nhớ của một trustlet. Một trustlet chỉ được import một danh sách trắng nhỏ các DLL được hệ thống tin cậy (CRT, KernelBase, Advapi, RPC runtime, CNG crypto, và vài thư viện toán) - những thứ không cần syscall để hoạt động. Người dùng không tạo chúng trực tiếp được; kernel tạo, thay mặt cho các tính năng bảo mật. Ví dụ kinh điển là LSAIso, process cô lập giữ các bí mật credential cho Credential Guard.

Các loại process trong một bảng

LoạiAi tạo raÝ chính
NormalBất kỳ aiProcess thường ngày.
Protected / PPLBất kỳ ai (cần ký đặc biệt)Kể cả admin cũng không đọc được bộ nhớ của nó; mức bảo vệ do signature level quyết định.
MinimalChỉ kernelCái vỏ rỗng - không PEB/TEB, không image, không ntdll. Ví dụ: memory compression.
PicoKernel + một driver pico providerMinimal process mà syscall/exception được một driver provider xử lý. Nền tảng của WSL1.
Trustlet (IUM)Kernel, cho các tính năng bảo mậtCô lập bằng hypervisor; OS bình thường không đọc được. Ví dụ: LSAIso / Credential Guard.

Job

Job là cơ chế của Windows để nhóm nhiều process lại dưới một đơn vị quản lý duy nhất. Mọi giới hạn hay thay đổi áp lên một job đều ảnh hưởng tới mọi process trong đó - khiến chúng hữu dụng cho việc giới hạn tài nguyên, khoanh vùng, và theo dõi một nhóm process liên quan như một khối.

Job object có thể chia sẻ, đặt bảo mật, và đặt tên. Một khi process đã vào job, nó không ra được. Process con do các thành viên sinh ra sẽ tự động vào cùng job, trừ khi CREATE_BREAKAWAY_FROM_JOB được truyền cho CreateProcess job cho phép breakaway.

Hai chi tiết hiện đại mà bức tranh kinh điển bỏ qua. Thứ nhất, từ Windows 8 job có thể lồng nhau - một process có thể thuộc một hệ thống phân cấp các job, với giới hạn có hiệu lực là cái nghiêm ngặt nhất trong chuỗi. Thứ hai, bạn có thể gắn một I/O completion port vào một job (JOBOBJECT_ASSOCIATE_COMPLETION_PORT) để nhận thông báo khi process vào, ra, hoặc chạm giới hạn - tốt hơn nhiều so với polling.

Job là nền tảng của Windows container. Silo là một job được tăng cường, có thêm khả năng ảo hoá object namespace. Server silo ảo hoá đủ phần hệ thống (registry, networking, object manager) để trông như một OS riêng - và đó chính là thứ đứng sau Windows Server Container và Docker trên Windows. Cái job object bình thường bạn sẽ tạo ở dưới cũng chính là primitive đó, chỉ được mở rộng lên tới tầm container.

Giới hạn của Job

Một số giới hạn bạn có thể đặt cho một job:

  • Số process active tối đa - chặn số process có thể tồn tại trong job. Khi đã đạt mức đó, không process mới nào được gán vào và việc tạo process con sẽ thất bại.
  • Processor affinity - giữ mọi thành viên trong một tập CPU cụ thể.
  • Priority class - đặt priority class cho mọi thành viên. Nếu một thread cố nâng priority class của nó lên trên mức này, yêu cầu sẽ bị bỏ qua âm thầm.
  • Giới hạn bộ nhớ ảo - chặn lượng bộ nhớ ảo mà một process, hoặc cả job, có thể commit.
  • Đọc/ghi clipboard - cấm mọi thành viên đọc hay ghi clipboard.

Các hàm API để làm việc với Job

Toàn bộ API của job:

HàmLàm gì
CreateJobObjectTạo (hoặc mở) một job object có tên
OpenJobObjectMở một job đã tồn tại theo tên
AssignProcessToJobObjectGán một process vào một job
SetInformationJobObjectÁp giới hạn lên job
QueryInformationJobObjectĐọc thông tin và thống kê hiện tại của job
TerminateJobObjectKết thúc mọi process trong job
IsProcessInJobKiểm tra một process có thuộc một job cụ thể hay không

Dùng Job

Quy trình là: tạo job, gán process, đặt giới hạn.

Tạo job bằng CreateJobObject:

1HANDLE CreateJobObjectA(
2  [in, optional] LPSECURITY_ATTRIBUTES lpJobAttributes,
3  [in, optional] LPCSTR                lpName
4);

Truyền NULL cho cả hai tham số để lấy một job không tên với security mặc định. lpName đặt tên cho job để chia sẻ giữa các process; nếu tên đó trùng với một mutex, file-mapping object, hoặc waitable timer đã có, lời gọi sẽ thất bại.

Gán process bằng AssignProcessToJobObject:

1BOOL AssignProcessToJobObject(
2  [in] HANDLE hJob,
3  [in] HANDLE hProcess
4);

hJob là handle của job; hProcess là handle tới process bạn đang thêm vào. Dùng GetCurrentProcess() để lấy handle tới chính process đang gọi.

Áp giới hạn bằng SetInformationJobObject:

1BOOL SetInformationJobObject(
2  [in] HANDLE             hJob,
3  [in] JOBOBJECTINFOCLASS JobObjectInformationClass,
4  [in] LPVOID             lpJobObjectInformation,
5  [in] DWORD              cbJobObjectInformationLength
6);

JobObjectInformationClass chọn nhóm giới hạn bạn đang đặt (basic, extended, UI, v.v.); lpJobObjectInformation trỏ tới cấu trúc tương ứng.

Ví dụ thực tế: Cargo hạ cả cây process của nó thế nào

Job’s Cargo explain

Khi bạn nhấn Ctrl-C trong lúc cargo build, bạn mong cả cây process chết theo - cargo, rustc, mọi build script. Trên Unix chuyện này có sẵn: Ctrl-C gửi signal cho cả process group. Trên Windows thì không - chỉ cargo chết, để lại các process con thành mồ côi.

Cách Cargo xử lý nằm ở src/cargo/util/job.rs: tạo một Job Object, set KILL_ON_JOB_CLOSE, và gán chính cargo vào đó. Process con tự động kế thừa job, nên cả cây được nhóm lại mà không cần theo dõi PID nào.

Job’s Cargo soruce code


Ví dụ code

Giờ khi phần nền tảng đã xong, hãy xem từng object trong số này trông thế nào trong thực tế.

Tạo một Process

CreateProcess đơn giản hơn danh sách tham số của nó gợi ra - phần lớn tham số mặc định là NULL.

 1#include <stdio.h>
 2#include <windows.h>
 3
 4int main(){
 5    STARTUPINFOA si;
 6    PROCESS_INFORMATION pi;
 7
 8    ZeroMemory( &si, sizeof(si) );
 9    si.cb = sizeof(si);
10    ZeroMemory( &pi, sizeof(pi) );
11    LPSTR lpCommandLine = "notepad.exe";
12
13    if( !CreateProcessA( NULL,   // No module name (use command line)
14        lpCommandLine,
15        0,           // Process handle not inheritable
16        0,           // Thread handle not inheritable
17        0,           // No handle inheritance
18        0,           // No creation flags
19        0,           // Use parent's environment block
20        0,           // Use parent's starting directory
21        &si,
22        &pi )
23    ){
24        printf( "CreateProcess failed (%d).\n", GetLastError() );
25        return -1;
26    }
27    printf("Process Created!\n");
28
29    Sleep(5000);
30
31    // Always close both handles - even if you never use them.
32    CloseHandle( pi.hProcess );
33    CloseHandle( pi.hThread );
34
35    return 0;
36}

Đoạn này chạy notepad.exe rồi thoát sau năm giây. Hai lời gọi CloseHandle ở cuối là bắt buộc - CreateProcess luôn trả cho bạn một process handle và một thread handle, và bạn sở hữu chúng bất kể bạn có làm gì với chúng hay không.

Tạo một Thread

 1#include <stdio.h>
 2#include <windows.h>
 3
 4DWORD WINAPI EthicalFunction(LPVOID lpParam)
 5{
 6    printf("Thread created\n");
 7    printf("For educational purposes only*\n");
 8    return 0;
 9}
10
11int main()
12{
13    HANDLE hThread = CreateThread(NULL, 0, EthicalFunction, NULL, 0, NULL);
14    WaitForSingleObject(hThread, INFINITE);
15    printf("Thread returned\n");
16    printf("Exiting...\n");
17    CloseHandle(hThread);
18    return 0;
19}

EthicalFunction chạy trên thread mới trong khi main bị chặn ở WaitForSingleObject. Khi thread trả về, main chạy tiếp, in thông báo của nó, và đóng handle. Cặp WaitForSingleObject + CloseHandle đó là mức cơ bản cho bất kỳ thread nào bạn tạo.

Tạo một Fiber

Fiber cần thêm một bước: bạn phải chuyển thread hiện tại thành fiber trước khi tạo thêm được.

 1#include <stdio.h>
 2#include <windows.h>
 3
 4VOID CALLBACK fiber_function(LPVOID lpParam)
 5{
 6    printf("Fiber created\n");
 7    printf("For educational purposes only*\n");
 8    // Switch back to the main fiber - fibers don't return normally.
 9    SwitchToFiber(lpParam);
10}
11
12int main()
13{
14    // Step 1: convert this thread into a fiber.
15    LPVOID Context = ConvertThreadToFiber(NULL);
16    // Step 2: create another fiber, passing our context so it can switch back.
17    LPVOID lpFiber = CreateFiber(0, fiber_function, Context);
18    // Step 3: switch to it. Execution transfers immediately.
19    SwitchToFiber(lpFiber);
20    // We're back. Clean up.
21    printf("Fiber returned\n");
22    DeleteFiber(lpFiber);
23    printf("Exiting...\n");
24    return 0;
25}

Để ý rằng fiber_function không trả về - nó chuyển ngược về fiber chính một cách tường minh. Đó chính là mô hình cooperative scheduling ở dạng thu nhỏ: bạn luôn là người kiểm soát thời điểm chuyển đổi xảy ra.

Tạo một Job Object

Ví dụ này tạo một job, đưa hai process vào đó ở trạng thái suspended, rồi resume chúng và kết thúc cả nhóm sau một phút.

 1#include <stdio.h>
 2#include <windows.h>
 3
 4int main()
 5{
 6    HANDLE hJob = CreateJobObject(NULL, "Unemployed");
 7
 8    // Kill all processes in the job when the job handle closes.
 9    JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli = {0};
10    jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
11    SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jeli, sizeof(jeli));
12
13    STARTUPINFOA si = {0};  si.cb = sizeof(si);
14    STARTUPINFOA si1 = {0}; si1.cb = sizeof(si1);
15    PROCESS_INFORMATION pi = {0};
16    PROCESS_INFORMATION pi1 = {0};
17
18    // Create both processes suspended so we can assign them before they run.
19    if (!CreateProcessA(NULL, (LPSTR)"notepad.exe", NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi) ||
20        !CreateProcessA(NULL, (LPSTR)"dvdplay.exe",  NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si1, &pi1))
21    {
22        printf("Error creating processes: %d\n", GetLastError());
23        return 1;
24    }
25
26    AssignProcessToJobObject(hJob, pi.hProcess);
27    AssignProcessToJobObject(hJob, pi1.hProcess);
28
29    ResumeThread(pi.hThread);
30    ResumeThread(pi1.hThread);
31    printf("Job created and processes added!\n");
32
33    Sleep(60000);
34    TerminateJobObject(hJob, 0);
35
36    CloseHandle(pi.hProcess);
37    CloseHandle(pi.hThread);
38    CloseHandle(hJob);
39
40    printf("Exiting...\n");
41    return 0;
42}

Cờ CREATE_SUSPENDED là chi tiết then chốt: nó cho bạn một khoảng để gán process vào job trước khi chúng chạy, tránh được một race condition mà process con có thể sinh ra process cháu trước khi việc gán có hiệu lực. Để xác nhận cả hai process đều nằm trong job Unemployed, mở Process Explorer và xem Properties → Jobnotepad.exe hoặc wmplayer.exe.


Những cái bẫy thường gặp

Vài cái bẫy hay cắn người làm việc với các API này:

  • Rò rỉ handle. CreateProcess trả cho bạn hai handle trong PROCESS_INFORMATION - process và thread. Quên CloseHandle cả hai là một chỗ rò rỉ chậm rất dễ bỏ sót vì chương trình vẫn chạy tốt.
  • CreateThread và C runtime. Nếu một thread gọi hàm CRT, hãy dùng _beginthreadex thay cho CreateThread - CreateThread thô để lại trạng thái CRT riêng của thread (như buffer của strtok) chưa khởi tạo hoặc bị rò rỉ.
  • Stack reserve cộng dồn lại. Mỗi thread mặc định dành riêng khoảng ~1 MB không gian địa chỉ. Sinh hàng nghìn thread trong một process 32-bit thì bạn sẽ cạn không gian địa chỉ từ rất lâu trước khi cạn CPU - một trong những lý do thread pool tồn tại.
  • Priority không phải là bảo đảm. Priority cao hơn làm một thread được ưu tiên, không phải được chạy trước - boost, affinity, và những core khác đang làm gì đều góp phần. Đừng dùng priority như một cơ chế đồng bộ.
  • Với tay tới fiber quá sớm. Chúng trông như thread giá rẻ nhưng mang theo những nguy hiểm về TLS/CRT và không cho bạn chút song song đa core nào. Mặc định hãy chọn thread, thread pool, hoặc async I/O.

Đối chiếu sang Linux

Nếu bạn xuất phát từ nền Unix, mô hình Windows tương ứng đại khái như sau:

WindowsLinux / POSIX
Process (CreateProcess)Process (fork + execve)
Thread (CreateThread)Thread / task (pthread_create, clone)
Fiber (CreateFiber)ucontext (makecontext/swapcontext), green thread
Thread affinitysched_setaffinity / taskset
Job objectcgroups (nhóm và giới hạn tài nguyên)
Access tokenuid/gid + capabilities
Handle / handle tableFile descriptor / fd table

Khác biệt lớn nhất về mặt khái niệm: Linux làm mờ ranh giới process/thread - cả hai chỉ là “task” sinh từ clone với các cờ chia sẻ khác nhau. Windows giữ một sự phân chia cứng và tường minh giữa cái hộp process và các thread bên trong nó.


Tổng kết

Ta đã đi qua bốn khối xây dựng của việc thực thi trên Windows - process (hộp chứa tài nguyên), thread (thứ mà kernel xếp lịch kiểu preemptive), fiber (đơn vị cooperative mà bạn tự xếp lịch), và job (nhóm process với giới hạn chung, và là primitive đứng sau Windows container) - cùng với các cấu trúc kernel (EPROCESS/KPROCESS, ETHREAD/KTHREAD) đứng sau chúng và những loại process đặc biệt mà Windows định nghĩa cho bảo mật và cô lập. Trên đường đi ta cũng xem scheduler thực sự quyết định chạy cái gì thế nào, CreateProcess gọi xuống kernel ra sao, và cách tạo từng object bằng code - cộng với cách quan sát tất cả trực tiếp bằng WinDbg hoặc Process Explorer.

Nếu bạn chỉ mang về ba điều: thread được xếp lịch cho bạn còn fiber được xếp lịch bởi bạn; gần như mọi cấu trúc ở đây đều kiểm tra được trên một máy đang chạy; và cái job object bạn vừa dùng để trông hai cửa sổ Notepad, khi mở rộng lên, chính là thứ chạy Windows container. Cảm ơn bạn đã đọc!


Tài liệu tham khảo