第一部分 GeoGeo脚本基础 第5章 多线程

5章多线程

5.1线程函数的声明和定义

5.1.1什么是线程函数

4章详细讲述了普通函数,并提及线程函数的概念。普通函数中,当程序的代码调用一个函数时,程序的控制从原来的代码转移到函数中执行,函数执行完毕后,程序的控制返回给原来调用该函数的代码中,然后继续向下执行。这个过程都在一个线程内顺序发生。而多线程函数是这样一些函数,当程序的控制调用线程函数时,马上创建一个新的线程并开始运行。新线程运行的同时,原来的程序继续执行(如果以阻塞方式启动线程也可以等待线程函数结束后再向下执行)。这些新启动的线程由操作系统调度在不同的或者同一个CPU内核上运行。

GeoGeo在启动多线程函数时,返回一个本机的线程号。这是GeoGeo给自己的线程编号(不是操作系统给线程的ID号),通过这个线程号,可以查询和控制线程。

线程函数执行完毕后自然死亡。如果是以阻塞方式启动的线程函数,原来调用线程函数的程序被唤醒,继续向下执行。非阻塞方式时,子线程结束时原来启动线程函数的程序状态和位置都是不确定的。

需要时,子线程函数在执行完毕之前可以被杀死。

5.1.2声明和定义线程函数

同普通函数一样,线程函数也不需要预先声明,按下列格式定义线程函数:

thread <函数名>([<类型名><标识符>[,…]]){<语句>…}

线程函数的定义使用thread关键字开头。普通函数定义时这里应该是函数的返回类型。线程函数不能任意指定返回类型,是因为线程函数只返回该线程的线程号,类型是32位整型(int类型)。

接下来是线程函数的函数名,这里同普通函数一样,是一个任意指定的以字母或下划线开头的标识符,同样是大小写敏感。

再下面是一对小括号括起来的形参表,由类型名和标识符成对组成,数目不限,也可以没有。值得注意的是与普通函数不同,线程函数的参数是传值不是引用。也就是说在线程函数的参数中不能将线程函数中的计算结果返回到调用程序。

可见,线程函数的返回值和函数参数均不能返回函数的计算结果,因此线程函数向主程序返回数据是通过全局变量实现的。

下面是一个线程函数的定义示例

thread func(double x,double y){

       …

}

5.1.3小结

线程函数的声明和定义与普通函数基本相似,主要区别有如下几点:

①线程函数函数以关键字thread开头。

②线程函数不声明返回类型,但返回线程号。

③线程函数的参数是传值的,不向调用函数返回结果。

5.2线程函数的调用

5.2.1调用线程函数

调用线程函数和调用普通函数在形式上完全一样。调用一个线程函数会返回一个线程号,检索、查询状态等都需要使用这个线程号。启动多个线程时可以将线程号放入一个数组,以方便查询和使用。例如func是一个线程函数,循环启动时可以

int thr[10];

for(i=0; i<10; i=i+1){

Thr[i] = func();

}

还要注意的是线程函数的参数不回传计算结果到调用的程序中,这一点与普通函数是不同的。

下面是一个多线程函数的示例。在这个例子中,第1行到第13行定义了一个线程函数,该函数与普通函数的差异只在于函数开头使用了thread关键字。main函数在一个循环中启动该线程函数4次,这样有4个线程同时运行。启动后等待线程自然结束。

程序清单 5.1  5-1-多线程函数.c

1 thread func(double x,double y,int n){

2    //生成个0~1000之间的随机数,作为次循环次数

3    Sleep(200*n); //每个线程不同的休眠时间作为随机数种子

4    int rn[2];

5    Rand(0,1000,rn);

6    int i,j;

7    for(i=0; i<rn[0];i=i+1) {

8        for(j=0; j<rn[1]; j=j+1){

9             x = x*x*y*y;//无意义耗时

10       }

11   }   

12   Print("线程%d完成计算!外循环次数%d ,内循环次数%d 。",n+1,rn[0],rn[1]);

13 }

14 main(){

15   double a = 3.14;

16   double b = 20.0;

17   int c;

18   for(c=0;c<4; c=c+1){

19       Print("线程%d启动",c+1);

20       func(a,b,c);

21   }

22   return;

23 }

代码运行输出结果:

线程 1启动

线程 2启动

线程 3启动

线程 4启动

线程 2完成计算!外循环次数 575,内循环次数 124

线程 4完成计算!外循环次数 538,内循环次数 846

线程 3完成计算!外循环次数 454,内循环次数 897

线程 1完成计算!外循环次数 859,内循环次数 921

在这段代码中,第3Sleep(200*n)使用了一个Sleep函数,这个函数使系统休眠,参数指定进程/线程休眠的毫秒数。由于每次传下来的n值不同,这样每个线程休眠的时间也不一样。

5行生成2个伪随机数,放在数组rn中。函数的第12个参数是伪随机数的上下限,第3个参数存放这些伪随机数。第3个参数的数据类型和数组维数及大小决定生成伪随机数的数据类型和数量。注意这些伪随机数的生成是使用系统时间做种子的,使用同样的时间种子会在每个线程中都生成同样的伪随机数。上述Sleep函数让每个线程休眠不同的时间就是为了避免这一问题。

线程函数的7~11行进行一些无意义的耗时运算,在第12行输出每个线程的信息。

该段代码中的main函数实质上也是一个线程,由于独立代码段被称之为过程(Procedure),因此main函数以及与之相关的非线程函数也称之为过程线程,或该进程的主线程。

在该例中过程线程在启动了4个子线程后自然死亡。4个子线程在运行结束后也自然死亡。挂起、恢复或者杀死线程在下面介绍。

此时顺序启动完成线程函数调用,同时注意多核CPU时进程占用CPU时间的不同。同时由于各个线程执行的任务量不同已经操作系统调度的不同,线程启动所在的CPU内核以及完成的时间顺序都会不一样。

去掉线程函数的thread关键字,该函数就变成了普通函数。此时运行结果可能为:

线程 1启动

线程 1完成计算!外循环次数 112,内循环次数 866

线程 2启动

5.2.2线程函数调用函数

线程函数可以调用其它函数,这些被调用调用的函数可以是普通函数或者线程函数,在线程函数中再次调用线程函数参见第5.5节线程的子线程。这里举例说明线程函数调用普通函数的问题。

线程函数调用普通函数时将普通函数复制一个副本到新线程,这样一个函数在一个新线程中执行与原来的主线程是隔离的,请看下面的例子。

程序清单 5.2  5-2-多线程函数2.c

thread func(double x,double y,int n){

    Sleep(200*n);

    int rn[2];

    Rand(0,1000,rn);

    int i,j;

    for(i=0; i<rn[0];i=i+1){

       for(j=0; j<rn[1]; j=j+1){

           x = x*x*y*y;//无意义耗时

       }

    }

    Output(n+1,rn[0],rn[1]);

}

main(){

    double a = 3.14;

    double b = 20.0;

    int c;

    for(c=0;c<4; c=c+1){

       Print("线程%d启动",c+1);

       func(a,b,c);

    }  

    Output(0,0,0);

    return;

}

int Output(int n1,int n2,int n3){

    if( 0 == n1 ){

       Print("主线程调用Output函数。");

    }

    else{

       Print("子线程%d调用Output函数:外循环次数%d内循环次数%d",n1,n2,n3);

    }

    msg(n1);

    return 1;

}

int msg(int n){

    if( 0 == n ){

       Print("主线程调用msg函数。");

    }

    else{

       Print("子线程 %d 调用msg函数",n);

    }

    return 1;

}

这个函数在函数清单5.1基础上进行了一点改动,线程函数不直接输出,而是调用一个名为Output的普通函数输出结果。同时在Output函数中又调用了一个msg函数,用来演示线程函数直接和间接调用普通函数。

代码运行的输出结果如图5.1

图 5.1多线程函数2.c的输出

5.3线程控制

5.3.1线程的检索和查询

一个进程/线程可以启动多个线程,每个线程有自己的ID和句柄,通过这些句柄或者线程ID可以实现线程的控制。GeoGeo给每个启动的线程一个编号,通过这个编号查询线程句柄或线程ID实现线程控制。线程编号在启动线程函数后返回,如:

int thrNo = func();

实际上在GeoGeo内,有一个内建线程列表,每个线程编号指向这个列表的一个元素。当需要检索和查询一个线程状态时,仅需要这个编号找到列表中对应的元素。

线程状态现仅设3种,活跃状态、挂起状态和死亡及线程异常。线程状态查询提供了一个查询函数ThreadStatus

int ThreadStatus(int thrNo);

函数的参数为线程编号,返回值为线程状态:0 =线程挂起,1 =线程活跃,-1 =线程死亡或异常。

5.3.2线程挂起和恢复

有多种理由需要挂起和恢复一个线程,例如一个线程需要处理一个数据的某一步骤,但线程启动时该数据的上一步处理尚未结束,这是就很可能需要暂时将线程挂起,待数据准备完成后恢复该线程。这些是线程同步时的基本需求。

    5.3.2.1 线程自挂起和被挂起

线程挂起通常有2种情况,①线程自己确定需要主动等待(自挂起),②外部进程/线程检测到某线程需要挂起等待(被挂起)。线程挂起使用SuspendSuspendThread函数。线程恢复使用ResumeThread函数。

①线程自挂起挂起使用下述函数:

int Suspend ();

该函数将当前线程(自己)挂起,此后的代码在挂起恢复前将不能继续执行。挂起成功时返回非0值,失败时返回0

②线程被挂起线程被挂起函数:

int SuspendThread(int thrNo);

该函数将一个指定编号的线程挂起,函数的参数为线程编号。挂起成功时返回非0值,失败时返回0

使用Suspend函数自挂起时无法自行唤醒自己,必须由其它进程/线程唤醒,使用自挂起的一个好处是能够确切知道线程的挂起点,以便线程恢复时准确继续执行。建议挂起后马上使用Sleep函数以便唤醒后执行程序代码的连续性。

使用SuspendThread函数挂起其它线程无法确切指定被挂起线程的挂起点。

    5.3.2.2 线程恢复

一个线程无论自己挂起还是被其它进程/线程挂起都不能自行恢复,因为挂起是线程自己处于一种僵死状态,必须由其它进程/线程恢复。线程恢复使用下述函数:

int ResumeThread(int thrNo);

函数的唯一一个参数是被挂起线程的编号。恢复成功时返回非0值,失败时返回0

    5.3.2.3 线程挂起/恢复示例

下面是一个线程挂起和恢复示例:

程序清单 5.3  5-3-线程控制1.c

1 thread func(int n){

2    int thrnum = GetThread();

3    if(thrnum%2){

4        Print("线程%d挂起!",thrnum);

5        Suspend();

6    }

7    int i;

8    double x=3.14;

9    for(i=0; i<100000;i=i+1){

10       x = x*x*x*x;//无意义耗时计算

11   }

12   Print("线程%d完成计算!",n+1);

13   return ;

14 }

15 main(){

16   int numTraeds = 8;

17   int i,susp,nThread[numTraeds];

18   for(i=0;i<numTraeds; i=i+1){

19       nThread[i] = func(i);

20   }

21   Sleep(10000); //休眠1

22   for(i=0;i<numTraeds;i=i+1){

23       susp = ThreadStatus(nThread[i]);

24       if( susp == 0 ){

25            Print("恢复线程%d ",nThread[i]);

26            ResumeThread(nThread[i]);

27       }

28   }

29   return;

30 }

这段代码在18~20行循环创建8个线程,将每个线程返回的线程编号保存到数组nThread中,然后main函数休眠10秒,再循环检查这些线程是否有挂起的。如果有,就将这些被挂起的线程恢复。22行循环检查每一个线程。23行使用ThreadStatus函数读取线程状态,这个函数的返回值如果是0,表示线程被挂起。如果是1,表示线程忙。如果线程已经挂起,在第26行使用ResumeThread函数将其恢复。

在线程函数func中,第2行使用GetThread函数获取线程编号,如果是单号,在第15行使用GetThread函数将其挂起。

这段代码的运行结果如下:

线程 1挂起!

线程 3挂起!

线程 5挂起!

线程 7挂起!

线程 6完成计算!

线程 8完成计算!

线程 2完成计算!

线程 4完成计算!

恢复线程 1

恢复线程 3

恢复线程 5

恢复线程 7

线程 5完成计算!

线程 1完成计算!

线程 7完成计算!

线程 3完成计算!

在上述线程函数func中去掉2~6行线程自己挂起的部分代码,在main函数19行之后添加下述代码:

程序清单 5.4  5-4-线程控制2.c

thread func(int n){

     int i;

     double x=3.14;

     for(i=0; i<100000;i=i+1)    {

         x = x*x*x*x;//无意义耗时计算

     }

     Print("线程%d完成计算!",n+1);

     return ;

}

main(){

     int numTraeds = 8;

     int i,susp,nThread[numTraeds];

     for(i=0;i<numTraeds; i=i+1) {

         nThread[i] = func(i);

        Sleep(10);

         if(nThread[i]%2){

              SuspendThread(nThread[i]);

         }

     }

     Sleep(10000); //休眠10

     for(i=0;i<numTraeds;i=i+1){

         susp = ThreadStatus(nThread[i]);

         if( susp == 0 ){

              Print("恢复线程%d ",nThread[i]);

              ResumeThread(nThread[i]);

         }

     }

     return;

}

代码含义是,启动线程后,如果线程编号是单号,就使用SuspendThread函数将其挂起。然后同样,休眠10秒后,将被挂起的线程恢复。

注意

Sleep(10); 

一行休眠10毫秒,防止下一行抢先挂起一个尚未启动的子线程!

5.3.3线程结束

一个线程函数执行完毕后可能会自然结束,也有许多线程循环等待处理消息或者窗口线程本身循环处理消息和事件等。一种方法是可以设置一个公有开关量,当外部进程需要结束该线程时,设置该开关量。线程一旦检查到该开关量被设置就结束线程。或者窗口程序通过消息机制结束线程等。

还有一种方法可以强行结束线程,即使用KillThread函数马上杀死一个线程。

int KillThread(int thrNo);

KillThread函数的参数为希望结束线程的编号,函数成功时返回一个非0值,否则返回0。这个函数通常在希望结束的线程的外部使用,如主进程监测到需要强制结束某线程可调用该函数来完成。

精心设计你的程序,尽量避免使用KillThread函数,最好让线程函数自然死亡。

下面是一个main函数启动线程后,强行杀死线程编号为单号的线程的代码:

程序清单 5.5  5-5-线程控制3.c

1    for(i=0;i<numTraeds; i=i+1) {

2        nThread[i] = func(i);

3    }

4    for(i=0;i<numTraeds;i=i+1){

5        sts = ThreadStatus(nThread[i]);

6        if( sts == 1 && nThread[i]%2){

7             Print("结束线程%d ",nThread[i]);

8           KillThread(nThread[i]);

9        }

10   }

2行启动线程后,在第5行循环检查所有线程的状态,如果线程状态为1(表示线程忙),并且线程号为单号,则在第8行杀死这个线程。

下面的代码演示了如何等待所有子线程结束后再继续执行下一步操作的例子:

程序清单 5.5续

1    int sts = 0;

2    while(sts != -1){

3        Sleep(10);

4        for( i=0;i<numTraeds;i=i+1 ){

5             sts = ThreadStatus(nThread[i]);

6             if(sts != -1){

7                  break;

8             }

9        }

10   }

11   Print("所有子线程结束!");

无论线程被强行结束还是自然死亡,其状态都被设定为-1,。这段代码循环执行,只要还有线程没有结束,就继续等待。

对于一些执行时间较长或者无法自行结束的线程,如果不使用KillThread函数强制结束,GeoGeo主线程在结束时不结束这些子线程,但是关闭这些子线程的句柄。这样可能会留下伪孤儿线程(或称GG孤儿线程)。之所以称之为伪孤儿线程是因为线程的父线程是GeoGeo解释程序,GeoGeo没有结束它就不是真正的孤儿。只是这些线程是由主线程代管的,主线程结束后就已经没有管理者管理这些仍然运行的线程了,貌似成了孤儿。

5.4过程线程

GeoGeo定义一个文件中包含main函数及其所调用相应函数的一个独立执行单元为一个过程。在一个过程中可以启动另一个过程,这个被启动的过程相对与启动进程就是一个单独的子线程,称之为过程线程。

相对于前面的函数线程,过程线程可以包含多个函数等资源,可以用于完成更为复杂的任务。

5.4.1启动过程线程

启动过程线程使用ThreadCal函数,函数原型如下:

int ThreadCall( STRING pathName, int mode);

函数的第1个参数为过程代码文件的路径名。第2个参数为线程运行方式,为1时指定线程以阻塞方式运行,为0时以非阻塞方式运行,缺省时(不指定)为非阻塞方式。

阻塞方式指启动子线程后,主进程/线程挂起等待,子线程结束后再继续执行。非阻塞方式子线程启动后,主进程/线程马上继续执行而不考虑子线程的状态。

下面的示例演示一个过程线程的应用。首先准备一段被调用的过程代码,如下述程序清单5.6,代码保存在一个名为“5-6-计时.c”的文件中。

程序清单 5.6 计时.c  5-6-计时.c

1 main(){

2    int count = 0;

3    DWORD tc = GetTickCount();

4    int thr = GetThread();

5    while(count < 3){

6        if(GetTickCount() - tc >= 5000){

7             tc = GetTickCount();

8             count=count+1;

9             Print("线程%d,计时次数%d ",thr,count);

10       }

11       Sleep(1);

12   }

13 }

这段代码可以独立运行,每隔5秒钟打印一行输出信息,一共循环打印3次。代码第4行获取线程编号,如果这段代码独立运行,获取的线程编号为0。第5行是一共3次的while循环。第6行用于计时,用GetTickCount函数获得的本次系统计时减去上一次的系统计时,如果达到5秒则开始输出信息。独立运行这段代码,系统输出为:

线程 0,计时次数 1

线程 0,计时次数 2

线程 0,计时次数 3

下面的示例代码用于启动程序清单5.6的代码。

程序清单 5.7  5-7-过程线程.c

1 main(){

2    STRING cmd = "D:\\计时.c";

3    int i;

4    //以非阻塞方式启动一个过程线程的个实例。

5    for( i=0; i<4; i=i+1){

6       ThreadCall(cmd);

7    }

8    Print("主进程/线程结束!");

9 }

上述代码第2行为上述“5-6-计时.c”文件的完整路径名,第5~7行以非阻塞方式启动该过程线程4次。运行代码,输出结果为:

主进程/线程结束

线程 1,计时次数 1

线程 3,计时次数 1

线程 2,计时次数 1

线程 4,计时次数 1

线程 1,计时次数 2

线程 3,计时次数 2

线程 4,计时次数 2

线程 2,计时次数 2

线程 1,计时次数 3

线程 4,计时次数 3

线程 2,计时次数 3

线程 3,计时次数 3

可见主线程/进程在启动子线程后马上结束,然后各线程函数输出运行结果。

注意:独立过程作为过程线程启动时,可能有全局变量声明。在反复启动该过程的线程实例时会重复声明这些全局变量。GeoGeo的规则是第一次声明有效,也就是说第一次声明了一个全局变量后,后续所有的同名声明都被视为无效,以保证全局变量的唯一性。

5.4.2过程线程的阻塞与非阻塞方式

上述代码是过程线程的非阻塞运行方式,代码第6ThreadCall(cmd)函数只写了一个参数,第2个参数缺省时默认为0,也可以写成ThreadCall(cmd,0)。将上述带代码第6行改为:

6            ThreadCall(cmd,1);

表示以阻塞方式启动线程,此时运行结果如下:

线程 1,计时次数 1

线程 1,计时次数 2

线程 1,计时次数 3

线程 2,计时次数 1

线程 2,计时次数 2

线程 2,计时次数 3

线程 3,计时次数 1

线程 3,计时次数 2

线程 3,计时次数 3

线程 4,计时次数 1

线程 4,计时次数 2

线程 4,计时次数 3

主进程/线程结束

仔细观察输出结果的次序,先启动第一个线程,持续15秒输出3次计数,然后再启动第2个线程,最后所有线程结束后,再输出主进程/线程的输出信息。运行时间也大为延长。这种运方式和不使用多线程方式基本相同。

Call函数与 ThreadCall函数相同。

5.5线程的子线程

在一个线程中可以启动另外的子线程,是普通的函数式线程还是过程线程没有特别的限制。所有这些子线程对于主进程来说都是等同的,有统一的线程编号。将上一节调用“计时.c”的main函数加以改造,并增加2个线程函数:

程序清单 5.8  5-8-过程线程2.c

1 main(){

2    Print("这里是主进程/线程,线程号%d ",GetThread());

3    func1();

4    func2();

5    Print("主进程/线程结束!");

6    return 1;

7 }

8 Thread func1(){

9    Print("这里是线程func1,线程号%d ",GetThread());

10 }

11 thread func2(){

12   Print("这里是线程func2,线程号%d ",GetThread());

13   STRING cmd = "G:\\MyProjects\\脚本\\RSDScript\\脚本代码示例\\计时.c";

14   int i;

15   for( i=0; i<4; i=i+1)

16   {

17       Call(cmd);

18   }

19 }

main函数启动2个函数线程func1func2,其中func2又启动4个过程线程。代码运行结果如下:

这里是主进程/线程,线程号0

这里是线程func1,线程号1

主进程/线程结束!

这里是线程func2,线程号2

线程 3,计时次数 1

线程 4,计时次数 1

线程 6,计时次数 1

线程 5,计时次数 1

线程 3,计时次数 2

线程 5,计时次数 2

线程 4,计时次数 2

线程 6,计时次数 2

线程 4,计时次数 3

线程 3,计时次数 3

线程 6,计时次数 3

线程 5,计时次数 3

5.6数据集锁

对资源的访问控制是线程同步的重要内容,GeoGeo也设计了自己的同步机制。上述线程控制一节通过线程的挂起、恢复,可以实现部分同步功能。此外还设计了数据集的锁定功能。

GeoGeo的每个数据集都是一个对象,不但有数据本身,还有描述该数据的一些辅助信息和部分存取代码。这样做的优点是可以方便对数据进行查询,可以设定数据的状态以及其它一些对数据的控制。例如可以设定数据锁定的状态并向被授权的线程分发访问许可,以便允许其它线程的进行访问。

这样做的缺点也是显而易见的,单独的简单数据本来占用很少的存储空间,但是如果用数据对象来描述可能需要成百上千的字节,浪费了许多资源。考虑到GeoGeo主要是处理地理空间数据,海量是其重要特征。损失部分简单变量性能以换取对海量数据的方便是值得的。还有一个理由就是认为简单变量多为零星的和少量的,计算机性能的提高也可以抵消部分损失。

基于上述,对数据集推荐进行整体的或者分块的操作。对数据集单独元素的存取应尽量控制使用。不推荐在其它语言中常用的数组元素存取,如:

for ( i=0; i<1000; i=i+1){

       for ( j=0; j<1000; j=j+1)       {

              n[i][i] = data[i][j];

       }

}

类似的操作尽量使用GeoGeo的块操作,或者使用嵌入式c代码。

至此,已经知道对数据集可以锁定。存取锁定的数据集需要使用该锁的钥匙。锁定该数据集的线程拥有该锁的钥匙。数据锁的钥匙可以在授权的线程中传递,也就是可以像家里门锁的钥匙一样进行复制。

如果把一个数据集比喻为一个齿轮,加工这个数据集的各个线程就是不同工序上的各个机床。单独锁定数据就好像齿轮先被一个机床加工,加工完毕后由下一个机床加工下一道工序。一切看起来那么自然和顺理成章,这里我们称之为“工厂生产模式”。互斥和临界段很好地诠释了这种信息资源的处理过程。

如果这个齿轮很大,也可以容纳几台机床对它进行同时加工,虽然几台机床同时加工可能会引起一些问题,但是如果仔细设计加工过程,几台机床加工同一工序,也能很好地完成任务并且提高了工作效率。信号量的设计解决了这类问题。

再假设这个齿轮非常巨大,大到没有工厂能够容纳得下,这时可能会需要将各个工序的加工设备搬到齿轮附近现场加工,我们称之为“现场生产模式”。在这种情况下,单一一个机床加工一遍可能需要非常长的周期,效率会很低,使用多个相同工序层的机床同时加工会提高生产效率。实际上还有一种可选方案是:将这个大齿轮划分成一个个的区域,在每个区域内由多个机床对不同工序分别加工,将会极大提高工作效率。这就是GeoGeo使用的“局部锁”的设计特征。

5.6.1数据锁

GeoGeo的数据锁分全局锁和局部锁,普通内存变量仅使用全局锁,局部锁仅用于超大数据集。

全局锁使用Lock函数锁定数据。一个被锁定的数据集只能由加锁的线程访问(加锁的线程拥有钥匙),或者能被加锁线程授权的线程访问,而不能被其它线程访问。一个线程使用Lock函数加锁一个数据集,该数据集就处于被锁定的状态。Lock函数原型如下:

int Lock(STRING str, int flag);

函数的第一个参数是待锁定数据集(全局变量)的名称,第二个参数为锁定等待方式。该参数为0时表示自旋等待,请求访问该数据的线程处于间歇查询休眠(Sleep)状态,一旦检测到数据解锁,则立即继续执行;该参数为1时表示挂起等待,请求的数据被锁定时自行挂起,数据解锁时唤醒挂起线程。这个参数缺省时为0,可以不指定(状态1暂时不可用!)。

下面的例子演示了如何使用Lock函数加锁一个数据集。在一个过程线程文件“5-9-数据锁线程1.c”中,第一次启动线程实例声明了一个全局变量并锁定。此时再第二次启动这个线程实例则无法访问锁定的数据,线程处于自旋等待状态。主进程/线程也同样无法访问这个数据。待第一个线程实例休眠3秒结束后,解锁数据集,此时第二个线程实例和主进程/线程均可以继续访问这个数据。

过程线程文件“5-9-数据锁线程1.c”的源码如下:

程序清单 5.9  5-9-数据锁线程1.c

1 double dbA[4] = {1.0,2.0,3.0,4.0};

2 main(){

3    int i;

4    int thr = GetThread();

5    Print("子线程%d启动!",thr);

6    int lock = Lock(dbA);  //运行该过程线程的第一个实例锁定数据,返回值lock

7                           //第二个线程实例启动时,如果数据仍为锁定状态,返回

8    if(lock){ //数据锁定成功时(通常在第一个实例)才能运行此部分代码

9        Print("子线程%d锁定数据dbA !",thr);

10       Rand(0.0,1.0,dbA);

11   }

12   for( i=0; i<4; i=i+1){//这个循环任何线程实例均能执行

13       Print("子线程%d:dbA[%d] = %f",thr,i,dbA[i]);

14   }

15   if(lock){//在第一个实例数据锁定成功时才能运行此部分代码

16       Print("子线程%d休眠 3 秒后解锁数据dbA !",thr);

17       Sleep(3000);

18       Unlock(dbA);

19   }

20 }

主程序源码如下:

程序清单 5.10  5-10-数据锁.c

1 extern dbA;

2 main(){

3    int i;

4    STRING cmd = "D:\\5-9-数据锁线程1.c";

5    ThreadCall(cmd);   //第一次启动过程线程

6    ThreafCall(cmd);   //再启动一次

7    Sleep(50);    //休眠50毫秒,确保向下执行之前线程锁定数据完成。

8    for( i=0; i<4; i=i+1){//数据解锁后,输出子线程产生的数据

9        Print("主进程/线程:dbA[%d] = %f",i,dbA[i]);

10   }

11 }

运行程序清单5.10的代码,输出如下:

子线程 1启动!

子线程 2启动!

子线程 1锁定数据 dbA

子线程 1 dbA[0] = 0.279702

子线程 1 dbA[1] = 0.996216

子线程 1 dbA[2] = 0.077731

子线程 1 dbA[3] = 0.151250

子线程 1休眠3秒后解锁数据 dbA

子线程 2 dbA[0] = 0.279702

主进程/线程: dbA[0] = 0.279702

子线程 2 dbA[1] = 0.996216

主进程/线程: dbA[1] = 0.996216

子线程 2 dbA[2] = 0.077731

主进程/线程: dbA[2] = 0.077731

子线程 2 dbA[3] = 0.151250

主进程/线程: dbA[3] = 0.151250

5-10-数据锁.c运行到代码第5行时第1次启动过程线程,在过程线程第6行将dbA锁定禁止其它线程访问。同时产生4个随机数赋予dbA(第1个线程可以访问自己锁定的数据集)。并在第13行输出dbA的内容。

5-10-数据锁.c运行到代码第6行时第2次启动过程线程,在过程线程第6行试图重新锁定dbA,但此时dbA已经被第1个线程锁定了,重新锁定失败,返回0。第811行代码不执行。执行到第13行时试图输出被锁定的dbA数组元素时,被迫中止等待dbA解锁。直道线程1休眠3秒后,解锁dbA,线程2继续向下执行。

注意:自子线程1对数据解锁后,子线程2和主进程/线程同时访问数据集dbA,实际应用中尤其是有写入的情况下应尽量避免,以防脏数据发生。

5.6.2访问请求

一个数据集被某线程实例锁定后,不能被其它线程实例访问。即使是相同过程线程的实例之间也不例外。而在实际应用中,有遇到大规模数组、图像等情况,需要进行某特定处理,可能会将数据或图像分成一个个区域。由于处理算法是相同的,可以使用相同的线程处理,只是每个线程实例有不同的开始和结束区域。举一个简单的例子,如对一个很大的矩阵求均值将这个矩阵分成若干块,每块求和,再求总和和均值。这时如果某线程实例锁定数据后,其它线程实例可以请求访问许可。

请求访问锁定数据使用GetKey函数:

int GetKey(STRING str);

函数参数是锁定数据集(全局变量)的名称。请求成功时返回1,不成功时返回0

下面对程序清单5.10的过程线程代码加以改动如下:

程序清单 5.11  5-11-数据锁线程2.c

1 double dbA[4] = {1.0,2.0,3.0,4.0};

2 main(){

8    if(lock){//数据锁定成功时(通常在第一个实例)才能运行此部分代码

9        Print("子线程%d锁定数据dbA !",thr);

10       Rand(0.0,1.0,dbA);

11   }

12  else{

13      Sleep(100);

14      i = GetKey(dbA);

15  }

16   for( i=0; i<4; i=i+1){//这个循环任何线程实例均能执行

17       Print("子线程%d:dbA[%d] = %f",thr,i,dbA[i]);

18   }

19   if(lock){//在第一个实例数据锁定成功时才能运行此部分代码

20       Print("子线程%d休眠秒后解锁数据dbA !",thr);

21       Sleep(3000);

22       Print("子线程%d休眠结束,数据解锁!",thr);

23       Unlock(dbA);

24   }

25  else{

26      FreeKey(dbA);

27  }

28   return;

29 }

将程序清单5.10

STRING cmd = "D:\\5-9-数据锁线程1.c"

一行改为

STRING cmd = "D:\\5-11-数据锁线程2.c"

运行该段代码,启动程序清单5.11的代码。在代码第12行,如果数据锁定不成功(lock值为0),可能数据是已经锁定后的,这里认为是该过程线程的第2个及其以后的运行实例。这时先休眠100毫秒,然后取得数据锁的钥匙,休眠100毫秒的意义在于等待第一个线程实例创建完毕随机数以后,再取钥匙。在第25~27行释放钥匙。

运行代码输出如下:

子线程 2启动!

子线程 1启动!

子线程 2锁定数据 dbA

子线程 2 dbA[0] = 0.221992

子线程 2 dbA[1] = 0.350963

子线程 2 dbA[2] = 0.468490

子线程 2 dbA[3] = 0.613849

子线程 2休眠3秒后解锁数据 dbA

子线程 1 dbA[0] = 0.221992

子线程 1 dbA[1] = 0.350963

子线程 1 dbA[2] = 0.468490

子线程 1 dbA[3] = 0.613849

子线程  2休眠结束,数据解锁!

主进程/线程: dbA[0] = 0.221992

主进程/线程: dbA[1] = 0.350963

主进程/线程: dbA[2] = 0.468490

主进程/线程: dbA[3] = 0.613849

这个例子中子线程2抢在了子线程1的前面,抢先锁定了数据。数据解锁前,两个子线程都实现了对数据集dbA的访问。也就是说,第2个线程是加锁的线程,自己有钥匙,第1个线程通过GetKey函数取得了钥匙,也实现了对数据的访问。数据解锁后,主进程/线程才可以对dbA访问。

5.6.3访问授权

加锁的线程可以直接授权给其它线程的运行实例,线程实例启动后需要访问锁定数据时,需要等待数据解锁,还可以检查线程自己是否已经被加锁线程授权访问。如果已经被授权,即拥有了该数据锁的钥匙,可以访问数据。同样使用GetKey函数

int GetKey(STRING str,int nThr);

在对数据加锁的线程中使用这个函数,第1个参数是已经锁定数据的变量名,第2个参数是授权该锁定数据访问权限的线程号。

被授权线程在访问锁定数据结束后应使用FreeKey函数释放钥匙。

下面用一个简单计算数据平均值的例子,来说明数据锁定、授权访问和线程调度的过程。首先生成一个用0~1之间随机数填充的25616384列的数组。然后分4个线程分别计算数组部分值的总和,再将分别计算的总和相加并计算平均值。

程序清单 5.12  5-12-数据锁-计算均值1.c

1 int row = 256;

2 int col = 16384;

3 int thrds = 4;

4 int lines = row/thrds;

5 double dbA[row][col];

6 double subsum[thrds];

7 double mean = 0.0;

8 main(){

9    int nThread[thrds];

10   DWORD tc = GetTickCount();

11   Rand(0.0,1.0,dbA);          //生成~1之间的随机数

12   tc = GetTickCount() - tc;

13   Print("生成随机数耗时%f秒",tc/1000.0);  

14   Lock(dbA);//锁定数据

15   tc = GetTickCount();

16   int i;

17   for(i=0; i<thrds; i=i+1){

18       //启动线程函数实例,由于数据是锁定的,启动后自旋等待。

19       nThread[i] = Mean(lines*i,0,lines,col);

20       //为启动的线程交送钥匙,线程实例结束等待。

21       GetKey(dbA,nThread[i]);

22   }   

23   int sts = 0; //等待所有线程执行完毕(自然死亡)

24   while(sts != -1){

25       Sleep(10);

26       for( i=0;i<thrds;i=i+1 ){

27            sts = ThreadStatus(nThread[i]);

28            if(sts != -1){

29                 break;

30            }

31       }

32   }

33   tc = GetTickCount() - tc;

34   Print("所有子线程结束!,耗时%d秒",tc/1000);

35   mean = 0.0;

36   for(i=0; i<thrds; i=i+1){

37       mean = mean + subsum[i];

38   }

39   mean = mean / (row * col);

40   Print("平均值为:%f",mean);

41   Unlock(dbA);//解锁数据(注意一定要解锁,否则主进程会被阻塞)

42 }

43 //对数据dbA计算累加值

44 thread Mean(int y,int x,int height,int width){

45   int i,j;

46   double sum = 0;

47   for(i=y; i<y+height; i=i+1){

48       Sleep(10);

49       for(j=x; j<x+width;j=j+1){

50            sum = sum + dbA[i][j];

51       }

52   }

53   subsum[y/height] = sum;

54   int thr = GetThread();

55   Print( "线程%d结束,从%d行到%d行,累加值为%f ",thr,y,y+height-1,subsum[y/height]);

56   FreeKey(dbA);

57 }

1~7行声明了一些全局变量。rowcol是数组的行数和列数,thrds是计划使用的线程数,这里是4个线程。lines是每个线程处理的数据行数。dbA是用于存放数据的数组。subsum[thrds]存放对应线程的部分总和。Mean用于保存最后的计算结果。

主程序在第9行声明了一个整型数组int nThread[thrds],该数组用于存放每个线程启动后返回的线程号。第10~13行生成随机数填充到dbA数组中,并统计输出使用的时间。第14行锁定数组dbA

17~22行循环启动线程函数Mean4个实例,其中第19

nThread[i] = Mean (lines*i, 0, lines, col );

Mean函数的第一个参数是数据起始行,第2个参数是起始列,第3个参数是处理的行数,第4个参数是处理的列数。可见每次循环线程实例处理不同行的数据,如第1次循环时处理第1到第64行数据(0~63),第2次循环处理65~128行数据,等。每次循环处理的列数都是一样的。

在第19行线程启动后函数马上返回,并不等待线程结束。函数返回值返回一个线程号,存放在nThread数组中。

实际上线程启动后并不能马上执行,因为此时数据是锁定的,线程处于等待状态。在第20行使用GetKey函数授权线程使用锁定数据,相当于交给了线程一把访问数据的钥匙。线程函数在等待是自动检测是否有授权发生,一旦获得授权,马上开始继续执行。

23~32行循环检查所有启动线程示例的状态,直至全部线程均已经完成,否则一直等待。一旦全部线程实例结束,在第36~39行将各部分累加和加在一起,计算平均值。

下面再分析一下线程函数thread Mean(int y,int x,int height,int width),在第47~52行两次循环计算累加值sum = sum + dbA[i][j]

建议像在第48行加Sleep函数,否则多线程占满CPU时间会造成“假死”。

下面是这段代码运行的输出结果:

生成随机数耗时0.218000

线程 2结束,从64行到127行,累加值为 524377.893215

线程 1结束,从0行到63行,累加值为 524158.976531

线程 3结束,从128行到191行,累加值为 524293.980651

线程 4结束,从192行到255行,累加值为 524690.992767

所有子线程结束!,耗时70

平均值为:0.500088

注意:尽量避免对单个数组元素的循环访问,求算数组元素需要消耗大量的时间,如上例中sum = sum + dbA[i][j]是最耗时的计算之一。GeoGeo是面向数据集的计算,提供了许多操作符计算和函数计算的手段。可以极大提高效率。如下一节的介绍。

5.6.4查询锁定状态

一个数据集是否已经锁定可以使用IsLocked函数查询。

int IsLocked( T var);

参数可以是任意类型,为需要查询是否被锁定的数据对象。返回1时表示数据是锁定状态,0时未锁定。

5.7隐含的多线程计算

GeoGeo的一些基本计算都是多线程实现的,这些计算隐藏在操作符运算和函数计算之后,如上一节计算平均值的例子。

程序清单 5.13  5-13-计算均值2.c

int col = 16384;

int row = 256;

double dbA[row][col];

main(){

     double sum;

     DWORD tc = GetTickCount();

     Rand(0.0,1.0,dbA);

     tc = GetTickCount() - tc;

     Print("生成随机数耗时%f秒",tc/1000.0);

     tc = GetTickCount();

    sum = MatSum(dbA);

     tc = GetTickCount() - tc;

     Print("计算总和耗时%f秒",tc/1000.0);

     sum = sum/(col*row);

     Print("平均值为:%f。",sum);

}

上述代码使用了MatSum函数进行了数组元素的求和,运行输出结果如下:

生成随机数耗时0.218000

计算总和耗时0.109000

平均值为:0.500064

计算数据总和仅耗时0.109秒,而上述5.6.3节的例子则使用了70秒。进一步详细的有关数值计算的操作符和函数的用法请参阅后面章节。

5.8本章小结

1. GeoGeo多线程有函数线程和过程线程两种。

2. 函数线程使用一种由thread关键字声明的函数,称之为线程函数。线程函数没有用户定义的返回值,但是它总是返回一个4字节的整型数据,即线程ID。线程函数的参数是传值的,不向调用者返回参数值。

3. 一段独立的GeoGeo代码(带mian函数的独立执行单元)称之为一个过程。使用ThreadCall函数启动一个过程就启动了一个独立的子线程。ThreadCall函数也返回线程的ID号。

4. 根据线程ID可以实现对子线程的查询和控制。

5. 可以从子线程启动更多的子线程,但无论是谁启动的子线程,所有子线程都有等同的身份和统一的ID编号。

6. 线程同步可以使用用户定义的全局变量和线程状态查询函数完成,同时对数据使用了数据锁的概念。线程对数据的锁定可以限制其它线程的访问,也可以允许授权的线程访问。

下载地址:http://download.csdn.net/detail/gordon3000/7922555 

猜你喜欢

转载自blog.csdn.net/gordon3000/article/details/39543609