《Win32多线程程序设计》读书笔记(二)

第二章

这章开始,正式进入多线程编程

创建线程

我们看看以下代码

#include <stdio.h>
#include <Windows.h>

DWORD WINAPI ThreadFunc(LPVOID);

int main()
{
    HANDLE hThread;
    DWORD threadId;

    hThread = CreateThread(NULL,
        0,
        ThreadFunc,
        0,
        0,
        &threadId
    );

    printf("Thread is running...\n");
    for (int i = 0; i < 20; i++)
        printf("Main:%d\n", i + 1);
    return 0;
}

DWORD WINAPI ThreadFunc(LPVOID p)
{
    for (int i = 0; i < 20; i++)
        printf("Thread:%d\n", i+1);

    return 0;
}

其中,DWORD表示一个双字(Double Word),而一个字(Word)代表两个字节(Byte),所以DWORD表示4个字节,共32位(bit)。也就是unsigned long你会发现原定义是typedef unsigned long DWORD;。这里的DWORD是函数返回值类型。

WINAPI实际上是_stdcall,代表函数的调用形式。

LPVOID实际上是void far*类型,是一种指针类型,而far类型和near类型实际上只在16位机上需要用到,far原本的作用是让指针指向另一个段内地址。32位机中没有far和near的区别

HANDLE表示句柄,即某个资源的标识符,此时用于标识线程,因为我们要创建线程。

CreateThread的函数原型为:

HANDLE CreateThread(
	LPSECURITY_ATTRIBUTES lpThreadAttributes,
    DWORD dwStatckSize,
    LPTHREAD_START_ROUTINE lpStartAddress,
    LPVOID lpParameter,
    DWORD dwCreationFlags,
    LPDWORD lpThreadId
);

其中,lpThreadAttributes描述线程的security (安全)属性,NULL表示缺省。

dwStatckSize描述线程堆栈的大小,0表示缺省:1MB

lpStartAddress描述开始的起始地址,即函数指针

lpParameter作为参数,传入上面函数指针指定的函数中。

dwCreationFlags运行线程暂时挂起,默认情况是立即执行。

lpThreadId产生的线程的id会被传回到这里。

所以,最上面的代码,就是一个默认的开启线程,执行ThreadFunc函数的程序。

运行结果:

单线程结果

可以发现,主线程和新开启的线程之间的执行顺序是未知的,这是根据操作系统的分配策略而产生的结果。

创建多个线程

我们知道了CreateThread函数可以创建线程,返回一个句柄,那么我们就可以通过for循环来生成多个线程,并进行分析:

#include <stdio.h>
#include <Windows.h>

DWORD WINAPI MyThreadFunction(LPVOID);

int main()
{
    HANDLE hThread;
    DWORD threadId;

    for (int i = 0; i < 5; i++)
    {
        hThread = CreateThread(NULL,
            0,
            MyThreadFunction,
            (LPVOID)(i+1),
            0,
            &threadId
        );

        if (hThread)
        {
            printf("Thread%d launched...\n", i+1);
        }
    }

    Sleep(3000);
    return 0;
}

DWORD WINAPI MyThreadFunction(LPVOID p)
{
    for (int i = 0; i < 10; i++)
        printf("%d\n", (int)p);
    return 0;
}

之前提到CreateThread函数可以给要调用的函数传参数,这里用强制类型转换:(LPVOID)(i+1)如果不强转,是会报错的

Sleep(3000);让主函数在结束前休止3秒钟,目的是等候所有线程均启动并执行完毕,否则可能有的线程还未启动或还未执行完毕主函数就返回了。

结果为:

多线程结果

1到5都无序地被打印了10次。虽然是Thread1-5,但实际上第二次运行可能是Thread1 2 4 3 5也说不准。

如果我们printf("%d%d%d%d%d%d%d%d\n",(int)p,(int)p,(int)p,(int)p,(int)p,(int)p,(int)p,(int)p);,那么可能还会出现3333444444443333的情况,这就是竞争条件

无序的原因,正是因为之前提到的:无法预测

我们的任务就是学会从多线程程序中获得可预期的结果,这也是《Win32多线程程序设计》这本书要教我们的内容。

上面的问题体现了目前多线程程序的几个特点

  1. 无法预期
  2. 执行次序无法保证
  3. Task Switches 可能随时随地发生
  4. 线程对小的改变高度敏感
  5. 线程并不总是立刻启动

核心对象 (Kernel Objects)

CreateThread函数返回的变量是一个句柄 (Handle),它还通过参数输出了一个线程id(Thread ID)。

线程id可以独一无二地表示系统任意进程中的某个线程。

句柄被称为核心对象 (Kernel Objects),由KERNEL32.DLL管理。它其实是一个指针,指向操作系统内存空间中的某个东西,但你没有权限直接获取那个东西,这是出于维护操作系统的完整性和安全性。

Win32核心对象清单:

  • 进程 (processes)
  • 线程 (threads)
  • 文件 (files)
  • 事件 (events)
  • 信号量 (semaphores)
  • 互斥器 (mutexes)
  • 管道 (pipes。分为 named 和 anonymous 两种)

GDI对象和核心对象的不同点

GDI对象有单一拥有者,不是进程就是线程。

核心对象可以有一个以上的拥有者,可以跨进程。核心对象保持了一个引用计数 (reference count) 来记录有多少handles对应到此对象,对象也同时记录哪一个进程或者线程是拥有者。调用类似CreateThread这种传回handle的函数,count会加1,调用CloseHandle函数,count会减1,一旦count为0,核心对象即销毁

CloseHandle的重要性

完成工作后,应该调用CloseHandle函数释放核心对象。

CloseHandle的函数原型为:

BOOL CloseHandle(
	HANDLE hObject
);

hObject代表一个已经打开的对象handle。

如果成功,返回TRUE,后则返回FALSE,如果失败,可以调用GetLastError获取到失败的原因。

如果我们没有事后CloseHandle的习惯,虽然操作系统会自动把count减1,但是我们自己的逻辑就会出问题。

如果一个进程频繁产生worker 线程,我们总是不关闭线程的handle,则会把它们全部堆给操作系统去“善后”,操作系统帮我们清理,这样则会出现资源泄露 (resource leaks)。这不是我们期望的结果。

tips:

worker 线程:指完全不牵扯到图形用户接口 (GUI),只做运算的线程。

优化之前的代码

了解了CloseHandle的重要性,我们就要优化之前的代码,加入CloseHandle了。

#include <stdio.h>
#include <Windows.h>

DWORD WINAPI MyThreadFunction(LPVOID);

int main()
{
    HANDLE hThread;
    DWORD threadId;

    for (int i = 0; i < 5; i++)
    {
        hThread = CreateThread(NULL,
            0,
            MyThreadFunction,
            (LPVOID)(i+1),
            0,
            &threadId
        );

        if (hThread)
        {
            printf("Thread%d launched...\n", i+1);
            //call CloseHandle here.
            CloseHandle(hThread);
        }
    }

    Sleep(3000);
    return 0;
}

DWORD WINAPI MyThreadFunction(LPVOID p)
{
    for (int i = 0; i < 10; i++)
        printf("%d\n", (int)p);
    return 0;
}

这样的代码,才更加严谨。

线程核心对象和线程的区别

线程的handle指向 “线程核心对象” ,而不指向线程本身。所以,可以在线程结束前关闭其handle,正如上面的代码中,我们实际在线程结束前就关闭了它的handle。

线程对象的默认引用计数是2,调用CloseHandle,count减1,线程结束,count减1,只有这两件事情都发生,这个线程对象才会被真正清除。

线程结束代码 (Exit Code)

之前的代码用Sleep函数来等待线程执行完毕,但实际上,如果CPU忙碌,可能等待结束时线程还在执行。

所以我们有个新的函数GetExitCodeThread可以用:

BOOL GetExitCodeThread(
	HANDLE hThread,
    LPDWORD lpExitCode
);

其中,hThread是要传入的线程的handle。

lpExitCode是一个指向DWORD的指针,用以接受结束代码 (exit code)。

成功返回TRUE,不成功返回FALSE并可用GetLastError获取错误信息。如果线程结束,则lpExitCode带回结束代码,否则带回STILL_ACTIVE

需要特别注意的是,不能通过GetExitCodeThread函数的返回值来判断一个线程是否结束,因为在一个线程还没有所谓的结束代码时,它就返回TRUE了。所以,不能从它的返回值知道线程在运行,还是已经结束,但返回值是STILL_ACTIVE

强制结束一个线程

如果想要暴力一点,可以用ExitThread函数,它的原型为:

VOID ExitThread(
	DWORD dwExitCode
);

dwExitCode指定调用此函数的线程的结束代码。

也就是说,在一个线程调用的函数中,调用这个函数,假设ExitThread(6),那么,这个线程立即结束,并且结束代码为6。

需要注意的是,ExitThread函数从不返回任何值,也就是说,调用它以后的任何操作(同一作用域内)将无效。

如果在主线程中使用ExitThread函数,则会导致主线程结束但"worker线程"继续存在,这样会跳过runtime library中的清理(cleanup)函数,导致已开启的文件没有清理,这样不好。

如果结束主线程,则会导致其他所有线程被强制结束

主线程(primary thread) ,负责GUI消息循环。它的结束会导致程序结束、其他线程被强制关闭,其他线程没有机会被清理。

MTVERIFY错误处理

MTVERIFY是一个,适用于GUI程序也适用于Console程序。它实际上是记录并解释Win32 GetLastError()而获得描述。如果Win32函数失败,MTVERIFY会打印出简短的文字说明。

使用方法,就是MTVERIFY(...),其中...是一个Win32函数。比如MTVERIFY(CloseHandle(hThrd));

微软的多线程模型

线程分为 GUI 线程worker 线程两种。

GUI线程负责建造窗口以及处理消息循环。

worker线程负责执行纯粹运算工作。

多线程设计的成功关键

  1. 各线程的数据要分离开,避免使用全局变量
  2. 不要在线程之间共享GDI对象
  3. 确定你知道你的线程状态,不要径自结束程序而不等待它们的结束
  4. 让主线程处理用户界面(UI)。

本懒狗更新于2020.04.13,可以进入下一章啦!

posted @ 2020-03-29 14:49  Lazy_V  阅读(185)  评论(0编辑  收藏  举报