windows多线程编程星球(一)

时间:2021-08-04 09:07:26

以前在学校的时候,多线程这一部分是属于那种充满好奇但是又感觉很难掌握的部分。原因嘛我觉得是这玩意儿和编程语言无关,主要和操作系统的有关,所以这部分内容主要出现在讲原理的操作系统书的某一章,看完原理是懂了,但是呢很难和编程实际结合起来,感觉很抽象,因为一般操作系统的书都没有啥代码。换到编程相关的书籍上面,讲语言的自然不会讲这个,讲编程技巧的也很难涉及到这一块。感觉就是这部分知识本来就不是很多,而且还散落在各个地方,想要原理实践都掌握有点收集七龙珠的感觉。这段时间工作正好接触这部分还挺多,遂觉得把我这一点点在编程上的领悟写出来,也许也有和当年一样需要的人。

上面说了多线程这东西多是和操作系统有关的知识,但是毕竟我主要目的是想通过具体的程序来具体化这个原理,争取能做到能从实践的角度说明白原理,所以我选择了windows那一套线程函数来说明这一切,虽然windows下面的线程也有很多弊端,但是说起来个人感觉要简单点。为了不成为枯燥的windows函数说明文章,我尽力想了一个小的场景来说明这一切,希望能够让文字有点吸引力。

为了不要文章太长,我决定分成两个部分,第一个部分从简单的开始,扯扯两个线程之间的二人世界,第二部分开始插入第三者,扯扯多个线程之间的复杂关系。

一、线程世界,星球与母星。

为了说明进程,线程之间的关系,我决定把我的这个命令行程序命名为“MyThrWorld”,Thr是Thread的前三个字母,随着要介绍的内容,我会慢慢的介绍我脑子里这个世界的信息。说是世界,但是世界上元素不会超过10个,毕竟先起个霸气的名字比较重要!

这个世界主要组成部分是“星球”,每个星球由以下几个主要属性:

struct Planet
{
    int type;   //星球类型
    int resource; //资源数目
    int speed; //行进速度
    int index; //编号
};
 

这个世界的创造者是一个母星,home planet,我把它初始化成这样。初始化的顺序和上面struct的顺序相同,用1代表母星类型,我是有点懒,最好是定义一个常量,这样可读性更强一些:

struct Planet homePlanet = InitPlanet(1,nMaxResource,0,0); 

nMaxResoure是整个输出的DOS窗口的最大宽度,这个的目的是为下面的程序里面画的图不要找不到去哪了,如何取得windows console的输出DOS窗口的最大宽度以及和这个窗口相关的更多信息呢?请搜索console api windows,在这里毕竟这不是重点,我们使用全局变量hout来标识输出的dos console窗口:

HANDLE hOut=GetStdHandle(STD_OUTPUT_HANDLE);

CONSOLE_SCREEN_BUFFER_INFO bInfo;
GetConsoleScreenBufferInfo(hOut, &bInfo );

nMaxResource = bInfo.dwSize.X;

假设这个home planet是这个世界的创造者,他的作用是分离出新的星球或者链接新的星球。 母星目前唯一的运动是旋转,我设计了一个十分简单的旋转逻辑,主要代码如下:

void PlanetSpin(const struct Planet &p,int spinInterval,int picType)
{
    char spin[4] = {'|','/','-','\\'}; 

    if( p.type == 1 )
    {
        MoveOutputToPos(0,0,&spin[picType%4],false);
        MoveOutputToPos(0,1,&spin[picType%4],false);
        MoveOutputToPos(1,0,&spin[picType%4],false);
        MoveOutputToPos(1,1,&spin[picType%4],false);

        ::Sleep(spinInterval);
    }
    else
    {
        ...
    }
}
 

else部分的代码后面再说明,大体意思就是让一个planet以某一个间隔旋转(spinInterval),其实也就是刷新时间,其中我把所有和在dos窗口中指定地点输出指定图形的内容分离出来用一个单独的函数,因为这部分在后面使用的还是挺频繁的并且会让我们遇到多线程编程的第一个重要知识点。MoveOutputToPos前两个参数是DOS窗口的横坐标和纵坐标,第三个是输出的字符,最后一个是为了后面输出string准备的,第一版代码大体是下面这个样子的,用到的还是console api。

void MoveOutputToPos(int x,int y,char* c,bool bString)
{
    csbiInfo.dwCursorPosition.X = x;
    csbiInfo.dwCursorPosition.Y = y; 

    if (!SetConsoleCursorPosition(hOut,csbiInfo.dwCursorPosition))
    {
        printf("SetConsoleCursorPosition error!!!!! \r\n");
        return;
    }

    if(!bString)
        printf("%c",c[0]);
    else
        printf("%s",c);
}

二、分离星球。

母星的第一个作用是分离出子星球,既然是子星球这么直白朴实的名字,我相信你也猜到了,我就是用这个表示子线程。一个子星球在线程世界里面就是一个独立的,但是又和母星有关联的个体,这也符合线程的基本特点。同样子星球也有资源属性并且都和母星球共用一个资源库,而且也会旋转。但是毕竟他不完全是母星球,还是有一点不同的,子星球可以前进。既然文章主要是介绍多线程,那么我们就从创建一个子星球来开始第一个多线程相关的函数。

HANDLE  hThread1 = CreateThread(0,      // SECURITY_ATTRIBUTES, 线程的安全属性,可以写一个专门的主题,0表示默认值
                             16 * 1024L, //为线程分配的堆栈大小,会在最后特别提一下这个,在PC上没啥感觉,但是我在嵌入式上经常被坑
                             ThreadProc, //线程执行函数,也就是你具体需要这个线程干嘛
                             &Planet1, //主线程传给线程函数的参数(地址块)
                             0, //一些标志位,比如你可以创建一个线程不让他立即执行
                             NULL);//返回线程id,如果你需要,可以定义一个dword,在这个函数调用成功后会写入线程的id

看到上面的函数,不得不自然说的是这个第三个参数,threadproc,这个参数的定义类型是”_In_      LPTHREAD_START_ROUTINE lpStartAddress”,_In_表示是一个输入值,中间这个LPTHREAD_START_ROUTINE是一个函数指针,定义如下:

typedef DWORD (__stdcall *LPTHREAD_START_ROUTINE) (
    [in] LPVOID lpThreadParameter
);

这个看起来超级复杂的式子其实就是指向一个返回值是dword并且没有参数的函数的一个指针,本质上是一个地址,所以用lpStartAddress作为参数的名字。有了这个说明,所以ThreadProc的函数原型一定是这样的了:

DWORD WINAPI ThreadProc(
  _In_ LPVOID lpParameter
);

在这个ThreadProc函数里面,目前只让子星球做两件事情,前进和旋转。而前进的动力来自于资源,但是你会发现貌似以目前介绍过的函数,你还没有办法控制这个资源和母星球资源之间的增减,所以我就先简单的假设子星球创建出来之后其资源是可以自己获取自己增加,这样他就可以不断的前进了。

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
    struct Planet* p = (struct Planet*)lpParameter;
    int nSpinType = 0;

    while(true)
    {
        PlanetMove(*p);
        PlanetSpin(*p,500,nSpinType);
        nSpinType++;
    }

    return 1;
}

void PlanetMove(struct Planet &p)
{
    char cLine[100];
    memset(cLine,0,100*sizeof(char));

    if( p.resource < nMaxResource-1 )
    {
        p.resource++;
        memset(cLine,'-',p.resource*sizeof(char));
        MoveOutputToPos(0,p.index*3+1,cLine,true); 
    }
 }

到这里,我们就可以补足PlanetSpin里面else的那一部分了,这一部分是为给子星球写的旋转代码。

else
 {
        char sep = '*';

        MoveOutputToPos(0,p.index*3-1,&sep,false);
        MoveOutputToPos(0,p.index*3,&sep,false);    

        MoveOutputToPos(p.resource,p.index*3+1,&spin[picType%4],false); 

        ::Sleep(spinInterval);
 }

目前看来,我们已经可以*的创建出一个星球,事实上,只要你愿意,你可以创建出多个。但是为了不要让问题搞的特别复杂,我还是先限定为一个。目前创建出来的星球一个最大的特点是不听使唤无法控制,你无法控制它前进与否,旋转与否,什么时候开始启动等等。因为一旦你仅仅是创建一个线程,你能对它做的很少很少。刚创建出来的线程就像一个熊孩子,无法控制,你得通过一些手段和方法来让它能够听从主线程的指挥或者和主线程互动。这也是后面慢慢要写的内容,也是多线程编程的核心内容了。

【更多】这里我最想讨论的是CreateThread里面的第二个参数,以及线程id和线程handle的区别。 CreateThread的第二个参数表示给线程分配的起始堆栈大小,这个大小是耗用进程的虚拟内存空间的,在PC系统上一个进程的虚拟内存空间大小至少都是4G,所以大多数时候这个参数所造成的影响基本真的是没有。而我经常碰到的嵌入式平台,一个进程的虚拟内存地址有的时候非常的小,比如wince5只有32mb(虽然系统是老的不能再老了,但是还是有用),如果一旦不小心几个线程的这个参数值没有注意,很容易一起来就挂了。而一个线程除了起始堆栈大小,还有reserved堆栈大小,你可以理解为reserved的堆栈大小是一个用来保护作用的线程最大能够使用的堆栈大小,这个reserved的默认值一般是设置在exe的头部的,默认值一般是1MB。那么如果你第二个参数设置的大于1MB会怎么样呢,这样系统会默认增长reserved的内存大小到离起始大小最近的1MB。 特别注意的是如果在倒数第二个参数中使用STACK_SIZE_PARAM_IS_A_RESERVATION,那么你就可以设置这个reserved的堆栈大小,这个时候,起始堆栈大小就是exe程序头中的一个默认值。另外值得特别注意的是,线程的堆栈空间只有在线程自己退出的情况下才会被释放,如果这个线程是被其他线程终止的,那么这部分堆栈空间将不会被释放。忘了说了,终止线程的函数是TerminateThread,只需要一个线程的handle,和一个exit code作为参数就可以。说到handle和线程id的区别,简单的说可以认为handle是我们写程序所使用的线程标识符而id是一个用户可读的线程标识符。

三、不友好的显示。

如果你使用上面的逻辑以及代码去运行一下这个程序,试图查看一下运行起来到底是什么样子的。我可以大胆预测你第一次运行看到的和你脑海里想的完全不一样,那你要说了,难道第二次就一样了吗?答案是还是不一样,第三次到第n次都和你想象的不一样并且很可能这n次相互之间也完全不一样。一种满屏的杂乱感,你会发现本有些该在指定位置输出的符号并没有输出,而是在另外一个风马牛不相及的地方输出了。调试一下吧,你会发现特别的不顺,甚至每一次运行结果行为都不一样。这就是多线程编程的一个特点和难点,难以调试并且预测,所以我们就需要对这种杂乱无章的行为进行约束和控制。

首先,分析一下为何会出现这样的行为,我们的函数不多,可以用注释一部分的办法来试试看看能不能找到问题在哪里。第一步,首先把createthread注释掉,或者倒数第二个参数使用CREATE_SUSPENDED,这样线程会被挂起,并不会被执行。你会发现,在只有一个主线程的情况下,一切运行如你所料,说明创建一个线程确实会导致不友好的显示问题。按照这个思路,接下来我们应该到线程函数threadproc里面找一找原因,一共也就俩函数,想到是显示出了问题,所以最大的怀疑对象是在函数MoveOutputToPos上面,既然这样,我们把move和spin里面MoveOutputToPos函数注释掉再试试,发现也没有问题。那么可以把注意点放在这个函数里面了。这个函数主要由两个部分组成,SetConsoleCursorPosition和printf,另外还有一个给CONSOLE_SCREEN_BUFFER_INFO变量赋值。为了一探究竟到底发生了啥,我们是稍微临时改造一下这个函数,传入星球的类型,这样我们就可以做一些输出了。第一步,现在变量CONSOLE_SCREEN_BUFFER_INFO赋值后面加入printf(“%d->x:%d,y:%d\r\n”,type,csbiInfo.dwCursorPosition.X ,csbiInfo.dwCursorPosition.Y)。运行一下你会发现,这个基本没有什么错误,坐标值对于每个类型基本都是你传入的都是正确的,那么为什么感觉设置的输出位置总是不对呢?只能把目光放在SetConsoleCursorPosition另外一个参数handle hout上面了,这个参数是是一个全局变量,那么从代码的角度出发也就是说作用域是整个代码文件,也就是说不管是main还是threadproc都是使用的这一个值,而在调用SetConsoleCursorPosition时,就可能发生主线程调用的这个函数但是下一秒切换成子线程的hout拷贝到寄存器上,造成了混乱。这就是多线程编程中的一个永恒而又核心的问题,资源的竞争。

资源,在任何世界里都是核心问题,无论是现实还是虚拟,一个世界的运行最本质的就是建立在资源的基础上。所有的战争本质上就是资源分配的不合理,无法满足自我的需求或者觊觎别人的资源。在计算机多线程的世界里,资源的问题严重的会导致程序崩溃,轻一点的会导致程序不当运行,虽然虚拟内存保证了资源是无法绕过进程的底线但是这也是无法容忍的。在我们目前的程序里面,有两个线程,主线程和子线程都能访问到这个进程的资源,也就是这个console windows。而计算机在运行多线程的时候实际上是以很快的速度不停的切换当前运行的进程,这样每个线程都可以分配到一点CPU的时间从而运行自己。为什么作为终端用户我们感觉不出来,是因为这个切换速度十分的快并且十分的频繁,导致我们根本感觉不到,所以给你一个假象是两个线程在并行(同时)的运行。这种切换是一个复杂的过程,但是可以推理的到就是他们会使用一些公用的空间,因为毕竟只有一个进程。在我们的这个例子里面,主线程和子线程在运行的时候都想在这个dos窗口上输出,所以他们会不停的试图抢占这个dos窗口,但是他们的输出坐标是各自的。所以会导致一个问题,在主线程计算出自己坐标之后突然发生了切换,这时候子线程取得dos窗口的控制权,于是本来该画在下面子星球位置上的符号画在了一个看起来很奇怪的位置上。

要解决这个问题的在多线程编程中有很多,而且后面遇到的时候也很多,我准备每次用一个不同的办法,在这里先介绍一个最简单的,CRITICAL_SECTION。CRITICAL_SECTION从名字中就能看出其霸气的作用,关键区域,读多了有种紧迫感。CRITICAL_SECTION其实简单的我觉得他就像一个看门人,在它锁定的区域它一次只放进来一个线程,如果屋里有人了,就不在允许第二个线程进入了。在本程序中,首先你得声明一个全局的CRITICAL_SECTION cs;然后得在main函数的最开始调用InitializeCriticalSection(&cs);来初始化,然后在离开main的时候得使用DeleteCriticalSection(&cs);来销毁这个对象。然后就是要更新一下MoveOutputToPos函数:

void MoveOutputToPos(int x,int y,char* c,bool bString)
{     

    CONSOLE_SCREEN_BUFFER_INFO csbiInfo;
    csbiInfo.dwCursorPosition.X = x;
    csbiInfo.dwCursorPosition.Y = y; 

    EnterCriticalSection(&cs);
    if (!SetConsoleCursorPosition(hOut,csbiInfo.dwCursorPosition))
    {
        printf("SetConsoleCursorPosition error!!!!! \r\n");
        LeaveCriticalSection(&cs);
        return;
    }

    if(!bString)
        printf("%c",c[0]);
    else
        printf("%s",c);
    LeaveCriticalSection(&cs);
}

里面有两个主要函数 EnterCriticalSection(&cs);和LeaveCriticalSection(&cs);第一个可以理解为开前门放一个线程进来然后关前门,第二个是开后门让一个线程出去然后关后门开前门等下一个进程。所以这两个函数要成对出现,无论你调用多少次,但是一定要配对,不然就出现前门关了再也没开过导致其他线程进不来或者前门一直大开没有任何效果。如此运行一下你会发现一切变得如你所料的显示了,因为在这里我们把主要之前的竞争资源hout做了管理。

【更多】本来感觉这一节感觉我应该深入的说明一下CRITICAL_SECTION以及各种相关函数的原理,但是我觉得我怎么写也不可能比这一篇更通俗易懂了:http://my.oschina.net/myspaceNUAA/blog/81244,所以我决定我稍微扯一下windows的进程。首先要明确的一点是进程并不会执行任何代码,操作系统调度和执行的最小单位是线程,而进程都至少有一个线程。那么进程到底有什么用呢?进程给线程的执行提供了环境,他是线程执行的容器,可以保证线程一直在每个进程空间中执行。如果两个线程在同一个进程中执行,那么这两个线程可以共享进程的地址空间,他们可以共享进程里面的代码和数据。所以你可以看到在我们的例子里面,主线程和子线程可以共同拥有hout,还可以共同获得home planet和planet1结构体里面的信息。打个比方,进程就像一个房子,线程就像住进房子里面的人,世界的运行并不是由房子发动的,而是由房子中的人来进行的,但是房子提供了人活动的边界和各种房间以及功能性的资源,在同一个房子中所有人原则上都能运用里面所有的房间和东西但是用不了别人房子的内容。就算互相交流也是以人与人为单位互相交流甚至和邻居交流,房子是不能互相交流的。

四、另一种形式的美好。

CRITICAL_SECTION提供了一种非常方便的同步方法并且是在user mode下的而不是kernel mode下实现的,几个相关函数也表达出了特别通俗易懂的解决方式。解决一个问题的方法往往不止一个,我一直都感觉如果把两个相似的东西放在一起对比一下最能体会到二者的不同与奥秘,所以在这一个部分,还是第三部分同样的问题,换一种方式一样能解决这个问题。

我想要在这里介绍的叫做mutex,mutex是Mutual Exclude的缩写,Mutual是彼此的意思,所以这个东西直白的翻译就是彼此的排斥,常见的中文翻译名称叫做互斥体。这个名称就很好理解了,其作用基本和CRITICAL_SECTION基本很像,也是在某一时间内,只有一个单位能够拥有资源(这里用单位没有用线程,因为Mutex是可以跨进程使用的)。如果想使用mutex,首先你的创建一个,ghMutex是一个global的handle对象:

ghMutex = CreateMutex(NULL,FALSE,NULL);

CreateMutex有三个参数,第一个是和createthread第一个参数的含义相同,第二个参数是表示创建者是否是这个mutex的初始拥有者,最后一个参数是你可以给这个mutex对象起一个名字,这样在以后的调用中,你可以使用这个名字唯一的找到这个mutex对象,于是在其他进程中你一样能得到这个mutex对象的句柄。说到获得一个已创建的mutex句柄,你还可以使用OpenMutex函数,这个函数提供获取一个命名mutex的功能并且能指定对这个mutex的权限。对于这部分内容我会在后面稍微扯扯我所知道的。当你有一个mutex对象了之后,想做到同步与控制,你还需要一个重要的函数,WaitForSingleObject,这个函数用来等待一个内核对象的状态变成被激活或者说获取一个激活状态的内核对象,其作用在多线程编程中差不多相当于封装对于C++的地位差不多。那么在介绍怎么用这个函数之前,先来解释一下什么叫激活状态。简单的来说,当你创建一个内核对象的时候,比如Mutex,那么在内核中它就拥有一个初始状态,在我们这个例子中,这是被激活的一种状态,也就是差不多等于我没有被任何人使用,现在十分的空闲。这时候调用WaitForSingleObject,那么一等就等到了,因为这个时候Mutex没事干,此时,内核会将这个mutex变成非激活状态。然后如果有其他线程再等待这个内核对象,那么这个忙碌的,非激活的Mutex就不能被等待,这个线程只能先登在关键资源的外面直至Mutex变成激活状态之后它才能拥有它。那么既然WaitForSingleObject之后Mutex会变成非激活状态,怎么让它重新变成激活状态呢?你只需要调用ReleaseMutex。

说了这么多,现在来具体介绍一下WaitForSingleObject和它的返回值,WaitForSingleObject需要两个参数,一个是对象的handle,一个是最大等待时间。如果在指定时间内等到一个激活的对象,那么就会返回WAIT_OBJECT_0,一般来说这个返回值就意味着你该做和关键资源相关的事情。如果最大等待时间都过了还没有等到一个激活对象,那么将会返回WAIT_TIMEOUT。另外,这个函数还可以返回WAIT_ABANDONED,这个返回值会在一个拥有mutex的线程在没有释放这个mutex的使用权的情况下就终止了,那么这个mutex的状态就变成了abandoned。如果这个函数返回的是一个WAIT_FAILED,那么就是说这个函数在执行的过程中出现了某种错误。所以稍微修改一下MoveOutputToPos函数,利用Mutex替换CRITICAL_SECTION。

void MoveOutputToPos(int x,int y,char* c,bool bString)
{
    CONSOLE_SCREEN_BUFFER_INFO csbiInfo;
    csbiInfo.dwCursorPosition.X = x;
    csbiInfo.dwCursorPosition.Y = y; 

    DWORD dStatus = WaitForSingleObject(ghMutex,1000*30);
    if ( dStatus == WAIT_OBJECT_0 )
    {
        if (!SetConsoleCursorPosition(hOut,csbiInfo.dwCursorPosition))
        {
            printf("SetConsoleCursorPosition error!!!!! \r\n");
            ReleaseMutex(ghMutex);
            return;
        }

        if(!bString)
            printf("%c",c[0]);
        else
            printf("%s",c);
        ReleaseMutex(ghMutex);
    }
    else
    {
        ReleaseMutex(ghMutex);
    }

}

最后,在main函数中应该用CloseHandle(ghMutex);来释放这个mutex对象。

【更多】这里我想扯的有两个,一个是CRITICAL_SECTION和Mutex之间的区别,第二个是Mutex这类东西是怎么实现对于资源的同步与控制的。

首先CRITICAL_SECTION是在用户模式下的一个实现,而Mutex是工作在内核模式的。这两个的主要差别就是在速度上,如果只工作和实现在用户模式,那么操作系统不需要进行上下文保存,系统调用或者中断等等,需要的指令自然就少,那么结果肯定是快。而Mutex得经历从application的代码用户模式到内核模式的转换,那么需要的工作就多了。其实用户模式和内核模式就是执行的CPU指令,为了不至于让应用程序跑着跑着就搞出了严重的系统错误,CPU一般把指令分成两种模式,特权模式和非特权模式,你可以理解为一些非常关键的指令,比如读取或改变诸如程序状态字之类控制寄存器的指令、原始I/O 指令和与内存管理相关的指令等等只能是像操作系统这样级别的总体调度者才能执行,如果谁都能执行,那么操作系统就乱了。这两个另外一个差别是Mutex是可以跨进程使用,而CRITICAL_SECTION肯定是不行的。最后一个我觉得重要的是如果拥有Mutex的线程非法的结束了,那么其状态会变成abandoned状态,这个时候你用WaitForSingleObject依然可以取得这个锁并且做一些工作,而CRITICAL_SECTION只会变成未知状态,你可能能不能再进入资源变成了未知。

关于Mutex是怎样实现资源的同步与控制的,按理说他也是一条条的指令,为啥它就不能被线程切换打断而SetConsoleCursorPosition就能呢?这里首先要说到的是原子操作,对于原子操作的定义是这样的:如果这个操作所处的层(layer)的更高层不能发现其内部实现与结构。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序是不可以被打乱,或者切割掉只执行部分。简单点的说就是原子操作不同于普通的指令操作,它是不可以被打断的,要么一次性执行完,要么一个也不执行。原子操作的实现是一个很深度也很具有技术难度的问题,具体的有兴趣的话可以查看http://www.cnblogs.com/fanzhidongyzby/p/3654855.html。而对于Mutex对象包含: 一个线程 ID ,使用计数和递归计数 。线程 ID 表示当前占用该互斥量的线程 ID ,递归计数表示该线程占用互斥量的次数,使用计数表示使用互斥量对象的不同线程的个数。通过这三个元素,Mutex才能完成同步与控制。这里还想扯扯的就是CreateMutex的第二个参数,如果第二个参数是ture,那么表示当前创建Mutex的线程拥有该mutex对象,也就是说当前线程先占用了该Mutex对象,在该线程调用ReleaseMutex之前,别的线程是无法通过WaitForSingleObject等待这个Mutex的。而ReleaseMutex的具体作用是每调用它一次将互斥对象的计数器减一,直到减到零为止,此时释放互斥对象,并将互斥对象中的线程id 置零。它的使用条件是,互斥对象在哪个线程中被创建,就在哪个线程里面释放。因为调用的时候会检查当前线程的id是不是与互斥对象中保存的id一致,若一致,则此次操作有效,不一致,则无效。

五、命令与控制。

从前面到这里,planet里面定义的resource属性到现在还没有使用,那么在这一节,我准备使用上这个属性。在我任性的逻辑里面我准备这样做,子星球每前进一步消耗一点资源,然后将这个资源输出在console的屏幕上的某个位置。那末就先构造好这个资源显示函数:

void UpdatePlanetResource(struct Planet &p)
{
    char cRes[3];
    itoa(p.resource,cRes,10);

    if( p.type == 1 )
    {
        MoveOutputToPos(2,0,cRes,true);
    }
    else
    {
        MoveOutputToPos(1,p.index*3,cRes,true);
    }
}

这个函数就是根据不同的星球类型显示目前他的资源数字。在我们的程序中,我们需要时时的更新这些资源数据,那么看起来应该很简单的一个问题,分别在主线程和子线程中相应位置调用这个函数就好了。仔细看一下这个想法,会发现一个问题,在我们的设计中,母星球旋转的速度和子星球前进的速度并不一样,母星球显示刷新周期是80ms而子星球是500ms,也就是说子星球没500ms消耗一点资源而这个时候母星球已经刷新了四个周期了。这样的话会有一个很现实的问题,你无法实现两个资源消耗额度的同步刷新。如果你单纯的只是在两个星球的while循环中调用上面的函数,你会发现两个资源的增加与减少并不同步。这时候会想到有没有一种办法,当子星球消耗了一点资源的时候才去通知母星球,然后让它去更新一下自己的资源?

为了解决成千上万和这个问题一样的问题,windows多线程编程中提供了一个叫Event的对象来实现这一通知响应机制。要使用Event,首先还得创建一个handle对象作为CreateEvent的返回值,类似于CreateMutex:

hEvent = CreateEvent( NULL , FALSE , FALSE , NULL );

相比于CreateMutex函数,CreateEvent有四个参数,第一个和最后一个参数的意义和CreateMutex相同。那么就只需要介绍一下第二个,第三个参数,第二个参数是是否手工设置状态,第三个是Event的初始状态是啥。要具体解释一下这两个参数就要先解释一下Event的状态,和Mutex一样作为一个内核对象,Event也有激活态和非激活态。如果第二个参数是false,那么在每次获得激活状态的Event对象之后,Event对象会自动设置为未激活状态,反之,则需要手工调用ResetEvent将其设置为未激活状态。第三个参数如果是True,那么这个Event的初始状态就是激活的,反之则是未激活的。

下面我们就要让子星球给母星球发送更新通知了,采用的函数是SetEvent,将event设置为激活状态,相当于给母星球发送一个通知,我们在子星球的PlanetMove里面加入如下代码:

void PlanetMove(struct Planet &p)
{
    char cLine[100];
    memset(cLine,0,100*sizeof(char)); 

    if( p.resource < nMaxResource-1 )
    {
        p.resource++;
        memset(cLine,'-',p.resource*sizeof(char));
        MoveOutputToPos(0,p.index*3+1,cLine,true); 

        UpdatePlanetResource(p);
        SetEvent(hEvent);
    }
}

在更新resource状态之后将event设置为激活状态,此时母星球线程中的wait函数就会等到这个event,然后更新自己的resource信息并显示,所以在main的循环中加入如下的代码:

 while(true)
    {
        PlanetSpin(homePlanet,80,nSpinType);
        nSpinType++;
        DWORD dStatus = WaitForSingleObject(hEvent,500);
        if ( dStatus == WAIT_OBJECT_0 )
        {
            homePlanet.resource -= 1;
            UpdatePlanetResource(homePlanet);
            // ResetEvent(hEvent); If the senconde para. is TRUE
        }
    } 

在wait函数中,如果等到了激活态的event,homeplanet的resource将会减一,同时更新显示resource。因为我们设置的event的auto reset,所以这里不需要调用resetevent再将其变成非激活状态。这里的resource虽然也是一个全局的资源,但是因为使用event进行了控制,所以在这里不需要mutex之类进行同步。这种event的控制,通知机制可以很好处理此类问题。

【更多】这一次我想扯的是CreateEvent和CreateMutex的第一个参数,无论在这两个里面哪一个,我们都是传递的NULL。首先看一下这里的NULL的含义是这样的:一个指向SECURITY_ATTRIBUTES结构的指针,确定返回的句柄是否可被子进程继承。如果lpEventAttributes是NULL,此句柄不能被继承(百度里复制的)。NULL还有一个含义就是这个对象获得默认的access rights,那么到底什么是access rights呢?windows有安全模型,这个模型规定用户去访问这些内核对象的权限,比如mutex,event还有后面要介绍的semaphore以及其他等等,如果有兴趣,请看这个https://msdn.microsoft.com/zh-cn/data/aa374876(v=vs.100)   ,又是一个值得深入了解的知识。用简单的话说,这个模型里面规定了一些权限,比如说SYNCHRONIZE允许该对象可以做同步,允许一个线程可以等待直到这个对象编程激活状态,关于这个其实是一个非常非常值得深入了解的知识,其中涉及的ACLs,DACL,SACL等等,了解这个可以对windows怎样控制安全有一定的帮助,我建议可以看看https://msdn.microsoft.com/en-us/library/windows/desktop/ms686670(v=vs.85).aspx

到目前为止的代码我都放在https://github.com/rogerzhu0710/MyTheWorld/ 这里了。