唐凌 发表于 2019-6-20 17:55:55

【多线程】在Windows内核中玩多线程编程

记得很久以前,毛利鸣人在群里问过我在Windows内核里玩多线程的事。我当时说:内核里线程概念没那么重了。
emmmm,这话该说是对是错我还说不清楚。因为在高中断请求级下,就没有线程的概念了。所谓的线程同步在高中断请求级下应该称之为多核同步。
不过在Windows内核里,玩玩多线程还是可行的,这里通过类比POSIX线程做个简单的介绍。

系统线程的创建与终止:
在Windows内核中,通过导出函数PsCreateSystemThread来实现创建系统线程。类似于POSIX线程中pthread_create函数。函数原型如下:
NTSTATUS PsCreateSystemThread(OUT PHANDLE ThreadHandle,IN ULONG DesiredAccess,IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,IN HANDLE ProcessHandle OPTIONAL,OUT PCLIENT_ID ClientId OPTIONAL,IN PKSTART_ROUTINE StartRoutine,IN PVOID StartContext);
参数介绍如下:
ThreadHandle:                用于接收被创建的线程句柄。句柄不再需要使用之后请关闭句柄。
DesiredAccess:                句柄对线程所拥有的权限,它是一个ACCESS_MASK类型的掩码。
ObjectAttributes:        用于描述线程对象句柄的属性。不可以设置为OBJ_PERMANENT, OBJ_EXCLUSIVE, 和 OBJ_OPENIF。从Windows XP起,如果该系统线程不被归入系统进程的上下文,则必须设置OBJ_KERNEL_HANDLE属性。在Windows 2000/98/Me中,该函数必须在系统进程上下文中调用。从Windows Vista起,该函数返回的句柄必然是内核句柄。
ProcessHandle:                用于指定被创建的系统线程归入的进程的句柄。该参数可选,填入NULL表示系统进程(PID=4)。
ClientId:                用于接收被创建线程的PID和TID。驱动创建线程时,应当设置为NULL。
StartRoutine:                线程起始函数地址。
StartContext:                线程函数的参数。
返回值为NTSTATUS错误码。当线程被成功创建时,函数返回STATUS_SUCCESS。
值得注意的是,ObjectAttributes是一个结构体,当指定OBJ_KERNEL_HANDLE属性时,方法如下:
OBJECT_ATTRIBUTES oa;
InitializeObjectAttributes(&oa,NULL,OBJ_KERNEL_HANDLE,NULL,NULL);
线程被创建后,会执行其线程函数。线程函数的原型为:
KSTART_ROUTINE ThreadStart;
void ThreadStart(IN PVOID StartContext)
{...}
原型和POSIX线程一样,只不过没有返回值罢了。

线程完成操作时,应当主动退出线程。在POSIX线程中,通过pthread_exit函数退出线程,在Windows内核系统线程中,通过PsTerminateSystemThread函数退出线程。函数原型如下:
NTSTATUS PsTerminateSystemThread(IN NTSTATUS Status);
通过填入一个NTSTATUS的代码表示线程任务的状态。通常填入STATUS_SUCCESS表示执行成功。

多线程编程中,需要等待一个线程执行完成。在POSIX线程中,通过pthread_join函数来等待线程完成。在Windows内核中,由于我们获取了线程句柄,因此使用ZwWaitForSingleObject来等待线程完成。函数原型如下:
NTSTATUS ZwWaitForSingleObject(IN HANDLE Handle,IN BOOLEAN Alertable,IN PLARGE_INTEGER Timeout OPTIONAL);
参数介绍如下:
Handle:        要等待的对象的句柄,这里直接填入PsCreateSystemThread返回的句柄即可。
Alertable:        该等待是否可以被叫醒。用于表示其他线程是否可以在该线程陷入等待时叫醒线程退出等待。
Timeout:        等待时长。值得注意的是,单位是100纳秒,正数表示绝对系统时间(即系统中的格林尼治时间),负数表示相对于执行该函数时的时间。举例:等待5毫秒时,设置为-50000。当设置为NULL时表示无限等待。
返回值为NTSTATUS错误码,可能情况如下:
STATUS_SUCCESS:        等待成功完成,在等待过程中没有被叫醒。
STATUS_ALERTED:        等待过程中被叫醒。
STATUS_INVALID_HANDLE:        句柄不正确。
STATUS_ACCESS_DENIED:        拒绝访问。一般是PsCreateSystemThread中没有赋予SYNCHRONIZE权限。
STATUS_TIMEOUT:        等待超时。
STATUS_USER_APC:        用户态APC被插入到当前线程。
值得注意的是,当使用NT_SUCCESS宏时,STATUS_SUCCESS,STATUS_ALERTED,STATUS_TIMEOUT,STATUS_USER_APC都被视为执行成功。
等待完成后,线程终止,此时应当使用ZwClose函数关闭句柄以避免句柄泄露。

“睡觉”是一种多线程实现定时器的功能,也可以用于实现简单的自旋锁多线程同步。在用户态里可以用Sleep函数来实现睡觉。在Windows内核中,通过KeDelayExecutionThread实现睡觉。函数原型如下:
NTSTATUS KeDelayExecutionThread(IN KPROCESSOR_MODE WaitMode,IN BOOLEAN Alertable,IN PLARGE_INTEGER Interval);
参数介绍如下:
WaitMode:        用于表示等待的模式。在系统线程中,应当用KernelMode。
Alertable:        该睡觉是否可以被叫醒。
Interval:        睡觉时长,单位是100纳秒。正数表示绝对系统时间(即系统中的格林尼治时间),负数表示相对于执行该函数时的时间。
返回值如下:
STATUS_SUCCESS:        睡觉完成,没有被叫醒。
STATUS_ALERTED:        等待过程中被叫醒。
STATUS_USER_APC:        用户态APC被插入到当前线程。
之前提到过睡觉和等待可以被叫醒,叫醒线程使用ZwAlertThread函数实现,函数原型如下:
NTSTATUS ZwAlertThread(IN HANDLE ThreadHandle);
填入线程句柄来叫醒指定的线程。注意这是个非文档化函数,使用这个函数时应当自行声明。
如果线程等待和睡觉时指定不可被叫醒,则这个函数不能叫醒线程。

在多线程编程中,线程安全尤为重要。我们需要实现多线程之间的同步。在Windows内核中,有以下简单的线程同步机制:
互斥锁、推拉锁、资源锁、自旋锁。
互斥锁(Mutual Exclusion,Mutex)是一种基本的线程同步机制。任何受互斥锁保护的数据都会在获取时陷入阻塞。
在Windows内核中,可以选择内核互斥锁(Mutex),快速互斥锁(Fast Mutex)和受保护互斥锁(Guarded Mutex)。
他们的区别是:
内核互斥锁性能最差,但线程递归性获取普通互斥锁时不会发生死锁。
快速互斥锁比内核互斥锁快,但线程递归性获取快速互斥锁时会发生死锁。
受保护互斥锁比快速互斥锁拥有更好的性能,但线程递归性获取受保护互斥锁时会发生死锁。
推拉锁(Push Lock)是一种支持读写分离的线程同步机制。推拉锁通过访问数据行为,将获取锁的行为分为共享锁和独占锁。当读取数据时,应当获取共享锁。当写入数据时,应当获取独占锁。
资源锁(Resource)也是一种支持读写分离的线程同步机制。和推拉锁一样,获取锁的行为分为共享锁和独占锁。当读取数据时,应当获取共享锁。当写入数据时,应当获取独占锁。
推拉锁和资源锁的区别在于:线程递归性获取推拉锁会发生死锁,资源锁则不会。而且,推拉锁未被微软官方文档化,是一种Windows内核内部使用的锁。
自旋锁(Spin Lock)是一种高度占用CPU核心的锁,但它是唯一允许在高中断请求级下使用的线程同步机制,因此受自旋锁保护的代码应当GKD。
这些线程同步机制都是基于获取-释放的代码模型。

关于互斥锁,这里对内核互斥锁略过不谈,直接谈快速互斥锁和受保护互斥锁。
快速互斥锁变量类型为FAST_MUTEX,受保护互斥锁变量类型为KGUARDED_MUTEX。在初始化一个互斥锁时,这个互斥锁变量不能是一个驱动中的全局变量,它必须以未分页内存的方式保存变量。通过ExAllocatePool函数,填入NonPagedPool参数分配一段未分页内存。为互斥锁变量分配出内存后,才能以ExInitializeFastMutex或KeInitializeGuardedMutex初始化互斥锁。
初始化互斥锁的函数原型如下:
void ExInitializeFastMutex(IN PFAST_MUTEX FastMutex);
void KeInitializeGuardedMutex(IN PKGUARDED_MUTEX GuardedMutex);
当获取互斥锁时,使用ExAcquireFastMutex或KeAcquireGuardedMutex函数。如果互斥锁已被获取,则获取互斥锁将陷入阻塞直到互斥锁被释放。
当释放互斥锁时,使用ExReleaseFastMutex或KeReleaseGuardedMutex函数。互斥锁释放时,其他获取锁的线程将停止阻塞。
如果不希望获取资源锁时发生阻塞,则使用ExTryToAcquireFastMutex或KeTryToAcquireGuardedMutex。获取成功时返回TRUE,反之则FALSE。
以上六个函数原型如下:
void ExAcquireFastMutex(IN PFAST_MUTEX FastMutex);
BOOLEAN ExTryToAcquireFastMutex(IN PFAST_MUTEX FastMutex);
void ExReleaseFastMutex(IN PFAST_MUTEX FastMutex);
void KeAcquireGuardedMutex(IN PKGUARDED_MUTEX GuardedMutex);
BOOLEAN KeTryToAcquireGuardedMutex(IN PKGUARDED_MUTEX GuardedMutex);
void KeReleaseGuardedMutex(IN PKGUARDED_MUTEX GuardedMutex);

推拉锁支持读写分离,但未被文档化,这里对推拉锁做简单的文档化。
推拉锁的变量类型为EX_PUSH_LOCK,在初始化一个互斥锁时,也必须是未分页内存的方式。通过ExInitializePushLock函数对其初始化,函数原型如下:
void ExInitializePushLock(OUT PEX_PUSH_LOCK PushLock);
获取推拉锁之前,应当进入临界区,使用KeEnterCriticalRegion函数进入临界区,函数原型如下:
void KeEnterCriticalRegion(void);
获取锁有共享锁独占锁两种形式,用ExfAcquirePushLockShared获取共享锁,用ExfAcquirePushLockExclusive获取独占锁。函数原型如下:
void __fastcall ExfAcquirePushLockShared(IN PEX_PUSH_LOCK PushLock);
void __fastcall ExfAcquirePushLockExclusive(IN PEX_PUSH_LOCK PushLock);
注意这里使用的是fastcall调用约定!
当获取共享锁时,如果推拉锁未被获取独占锁,则锁被立即获取,否则发生阻塞。也就是说多个线程可以同时以共享锁的方式获取一个推拉锁。
当获取独占锁时,如果推拉锁未被获取任何形式的锁,则锁被立即获取,否则发生阻塞。
在释放锁时,要对应获取方式实行释放锁,用ExfReleasePushLockShared释放共享锁,用ExfReleasePushLockExclusive释放独占锁。函数原型如下:
void __fastcall ExfReleasePushLockShared(IN PEX_PUSH_LOCK PushLock);
void __fastcall ExfReleasePushLockExclusive(IN PEX_PUSH_LOCK PushLock);
释放推拉锁后,应当离开临界区,使用KeLeaveCriticalRegion函数离开临界区,函数原型如下:
void KeLeaveCriticalRegion(void);
再次重申,递归获取推拉锁会引起线程死锁!

资源锁是一种同时支持读写分离且允许递归获取的文档化的线程锁。
资源锁的变量类型为ERESOURCE,在初始化一个资源锁时,也必须是未分页内存的方式。通过ExInitializeResourceLite函数对其初始化,函数原型如下:
NTSTATUS ExInitializeResourceLite(IN PERESOURCE Resource);
获取资源锁有共享锁和独占锁两种形式,其中共享锁还能细分为插队锁和排队锁两种形式。
获取独占锁时,用ExAcquireResourceExclusiveLite函数获取独占锁,函数原型如下:
BOOLEAN ExAcquireResourceExclusiveLite(IN PERESOURCE Resource,IN BOOLEAN Wait);
当Wait参数为TRUE时,函数等到所有共享锁和独占锁被释放后进行获取,并返回TRUE。
当Wait参数为FALSE时,如果不能立即获取独占锁,返回FALSE,反之则返回TRUE。
获取插队锁时,用ExAcquireSharedStarveExclusive函数获取插队锁,函数原型如下:
BOOLEAN ExAcquireSharedStarveExclusive(IN PERESOURCE Resource,IN BOOLEAN Wait);
这里对Wait参数和返回值不再赘述,简单讲讲插队锁的特征:
如果资源没有被获取独占锁,插队锁会在队列中的独占锁之前抢占获取共享锁。
获取排队锁时,用ExAcquireResourceSharedLite函数获取排队锁,函数原型如下:
BOOLEAN ExAcquireResourceSharedLite(IN PERESOURCE Resource,IN BOOLEAN Wait);
相对于插队锁,排队锁会等待资源锁队列中的独占锁——即使资源尚未被获取独占锁,排队锁也会等待队列中排在前的独占锁完成操作。这里假设Wait参数为TRUE。
当Wait参数为FALSE时,如果不能立即获取排队锁,返回FALSE。
操作完成时,应当释放锁,无论是何种锁,释放锁时统一使用ExReleaseResourceLite函数,函数原型如下:
void ExReleaseResourceLite(IN PERESOURCE Resource);
和推拉锁一样,在获取资源锁前,应当进入临界区,在释放资源锁后,应当离开临界区。
当资源锁不再使用时,应当使用ExDeleteResourceLite删除资源锁。函数原型如下:
NTSTATUS ExDeleteResourceLite(IN PERESOURCE Resource);
当资源锁被删除后,才能释放掉资源锁的内存。

自旋锁是一种可以在高中断请求级下使用的线程同步(多核同步)机制。它不支持读写分离,且大量占用CPU资源。
自旋锁的变量类型是KSPIN_LOCK,在初始化一个自旋锁时,也必须是未分页内存的方式。通过KeInitializeSpinLock函数对其初始化,函数原型如下:
void KeInitializeSpinLock(IN PKSPIN_LOCK SpinLock);
关于自旋锁的获取,在WDK7中就有五个函数实现获取,它们分别是:
标准获取自旋锁:        void KeAcquireSpinLock(IN PKSPIN_LOCK SpinLock,OUT PKIRQL OldIrql);
在DPC级获取自旋锁:        void KeAcquireSpinLockAtDpcLevel(IN PKSPIN_LOCK SpinLock);
在DPC级试图获取自旋锁:        BOOLEAN KeTryToAcquireSpinLockAtDpcLevel(IN PKSPIN_LOCK SpinLock);
线程DPC获取自旋锁:        KIRQL KeAcquireSpinLockForDpc(IN PKSPIN_LOCK SpinLock);
提升至DPC级获取自旋锁:        KIRQL KeAcquireSpinLockRaiseToDpc(IN PKSPIN_LOCK SpinLock);
有三个函数实现释放,它们分别是:
标准释放自旋锁:        void KeReleaseSpinLock(IN PKSPIN_LOCK SpinLock,IN KIRQL NewIrql);
从DPC级释放自旋锁:        void KeReleaseSpinLockFromDpcLevel(IN PKSPIN_LOCK SpinLock);
线程DPC释放自旋锁:        void KeReleaseSpinLockForDpc(IN PKSPIN_LOCK SpinLock,IN KIRQL OldIrql);
其中KeAcquireSpinLock和KeAcquireSpinLockRaiseToDpc获取的自旋锁应当用KeReleaseSpinLock释放;
KeAcquireSpinLockAtDpcLevel和KeTryToAcquireSpinLockAtDpcLevel获取的自旋锁应当用KeReleaseSpinLockFromDpcLevel释放;
KeAcquireSpinLockForDpc获取的自旋锁应当用KeReleaseSpinLockForDpc释放。
注意KeAcquireSpinLock返回的OldIrql通常放进局部变量,虽然可以放进全局变量,但绝对不能用于两个不同的锁,否则会引起竞态条件。
在自旋锁中,不要访问分页内存,不要产生异常,并且GKD,获取后尽快释放。对在获取自旋锁时运行的代码应当做到最优优化。

本文介绍了在Windows内核中玩多线程的方法,涉及了线程的创建退出等待,睡觉与叫醒,以及四种可用于线程同步的锁:互斥锁,推拉锁,资源锁,自旋锁。
因为时间关系就不写示例代码了。以后可能会补充。

Ink_Hin_fifteen 发表于 2019-6-22 22:43:59

知识有限,还没能看懂,不过我会努力的。

Golden Blonde 发表于 2019-6-23 02:56:13

这个总结太牛逼了。

watermelon 发表于 2020-1-27 17:07:03

牛,我看懂了OBJECT_ATTRIBUTES结构体和InitializeObjectAttributes宏,之前python学过多线程里面的mutex lock所以还稍微看懂一些互斥锁,最让我感觉兴奋的是在内核中的实现Sleep函数来着,由于Sleep是winsdk里面用在r3层面的api, 苦恼了好长时间才有人说内核中用KeDelayExecutionThread来实现Sleep功能,要是当初早些时间看到这个帖子就好了:lol

watermelon 发表于 2020-1-27 18:55:09

小弟我简单根据本帖子上的api指导和win32sdk编程中的多线程程序写了一个win7 x64的互斥锁的多线程内核程序的例子,为了便于理解,小弟写了一个python的程序作为对应。
例子采用互斥锁的应用中的给共享资源(这里以一个全局变量为例)进行计数来说明互斥锁的应用,其具体原理就是本帖子中说明的堵塞和解堵塞的原理。
python程序(python 3.7 amd64)
import threading
import time

# global parameter.
g_num = 0


def thread1(num):
    global g_num

    mutex.acquire()
   
    for i in range(num):
      g_num += 1

    mutex.release()

    print("thread 1: g_num=%d" % g_num)
   


def thread2(num):
    global g_num

    mutex.acquire()
   
    for i in range(num):
      g_num += 1

    mutex.release()

    print("thread 2: g_num=%d" % g_num)


# Create a mutex lock, and the defaulted is unlocked.
mutex = threading.Lock()


def main():
    th1 = threading.Thread(target=thread1, args=(10000000,))
    th2 = threading.Thread(target=thread2, args=(10000000,))

    th1.start()
   
    th2.start()

    time.sleep(3)
   
if __name__ == '__main__':
    main()
   

运行结果:


C语言 win7 x64 内核程序:
#include <wdm.h>
#include <windef.h>

// Global parameter.
unsigned long g_number = 0;

void thread1(void *mutex)
{
        unsigned long i;
        PKMUTEX pkMutex = (PKMUTEX)mutex;

        KeWaitForSingleObject(pkMutex,
                Executive,
                KernelMode,
                FALSE,
                NULL);
        DbgPrint("This is Thread 1...\n");

        for (i = 0; i < 10000000; i++)
        {
                g_number++;
        }

        KeReleaseMutex(pkMutex, FALSE);

        // Print the result of g_number in thread 1.
        DbgPrint("Thread 1:g_number = %ld\n", g_number);

        PsTerminateSystemThread(STATUS_SUCCESS);
}

void thread2(void *mutex)
{
        unsigned long i;
        PKMUTEX pkMutex = (PKMUTEX)mutex;

        KeWaitForSingleObject(pkMutex,
                Executive,
                KernelMode,
                FALSE,
                NULL);
        DbgPrint("This is Thread 2...\n");

        for (i = 0; i < 10000000; i++)
        {
                g_number++;
        }

        KeReleaseMutex(pkMutex, FALSE);

        // Print the result of g_number in thread 2.
        DbgPrint("Thread 2:g_number = %ld\n", g_number);

        PsTerminateSystemThread(STATUS_SUCCESS);
}


void DriverUnload(IN PDRIVER_OBJECT pDrvObj)
{
        DbgPrint("DriverUnload....\n");
}

NTSTATUS DriverEntry(PDRIVER_OBJECT pDrvObj, PUNICODE_STRING RegistryPath)
{
        NTSTATUS status = STATUS_SUCCESS;
        pDrvObj->DriverUnload = DriverUnload;

        // TODO: mutex-lock thread test.

        HANDLE hThread1, hThread2;
        KMUTEX mutex;

        // Create the mutex lock.
        KeInitializeMutex(&mutex, 0);

        // Create the thread.
        PsCreateSystemThread(&hThread1,
                0,
                NULL,
                NtCurrentProcess(),
                NULL,
                thread1, // thread proc
                &mutex);

        PsCreateSystemThread(&hThread2,
                0,
                NULL,
                NtCurrentProcess(),
                NULL,
                thread2, // thread2 proc
                &mutex);

        void *Queue;
        ObReferenceObjectByHandle(hThread1,
                0,
                NULL,
                KernelMode,
                &Queue,
                NULL);
        ObReferenceObjectByHandle(hThread2,
                0,
                NULL,
                KernelMode,
                &Queue,
                NULL);

       
        KeWaitForMultipleObjects(2,
                Queue,
                WaitAll,
                Executive,
                KernelMode,
                FALSE,
                NULL,
                NULL);

        // Subtract the count the object reference and
        // release the system resources.
        ObDereferenceObject(Queue);
        ObDereferenceObject(Queue);

        // Sleep 2 seconds, wait for the threads completely
        // finish their work.
        LARGE_INTEGER sleep;
        sleep.QuadPart = -20 * 1000 * 1000;
        KeDelayExecutionThread(KernelMode,
                FALSE,
                &sleep);

        DbgPrint("The process is terminated...\n");
        return status;
}

运行结果:

唐凌 发表于 2020-1-27 21:55:51

watermelon 发表于 2020-1-27 18:55
小弟我简单根据本帖子上的api指导和win32sdk编程中的多线程程序写了一个win7 x64的互斥锁的多线程内核程序 ...

随便说几句吧。
1. 在Windows内核里锁对象必须放在不可换出内存里。也就是说必须要用ExAllocatePool(WithTag)分配一段NonPagedPool给锁对象。每个初始化锁函数的文档都会提到这句话。
2. 我在帖子里没说怎么用内核互斥锁。。。
3. 创建线程没必要引用他们的线程对象,直接ZwWaitForMultipleObjects即可,这是个导出函数。
4. 你虽然记得要解引用以免对象泄漏,但你忘记关闭线程句柄了。
5. 你发这个帖子的时候python3.8.x都出了。。。

watermelon 发表于 2020-1-27 23:41:02

tangptr@126.com 发表于 2020-1-27 21:55
随便说几句吧。
1. 在Windows内核里锁对象必须放在不可换出内存里。也就是说必须要用ExAllocatePool(With ...

学习了!不过好像的确是忘记关闭hThread1和hThread2了(汗

watermelon 发表于 2020-1-27 23:44:12

tangptr@126.com 发表于 2020-1-27 21:55
随便说几句吧。
1. 在Windows内核里锁对象必须放在不可换出内存里。也就是说必须要用ExAllocatePool(With ...

是的,最新的python正式发布版本是3.8,并且刚看了一下,python3.9已经出测试版了:lol
页: [1]
查看完整版本: 【多线程】在Windows内核中玩多线程编程