1.   多线程的那点儿事(基础篇)


 多线程编程是现代软件技术中很重要的一个环节。要弄懂多线程,这就要牵涉到多进程?当然,要了解到多进程,就要涉及到操作系统。不过大家也不要紧张,听我慢慢道来。这其中的环节其实并不复杂。

    (1)单CPU下的多线程

     在没有出现多核CPU之前,我们的计算资源是唯一的。如果系统中有多个任务要处理的话,那么就需要按照某种规则依次调度这些任务进行处理。什么规则呢?可以是一些简单的调度方法,比如说

    1)按照优先级调度

    2)按照FIFO调度

    3)按照时间片调度等等

    当然,除了CPU资源之外,系统中还有一些其他的资源需要共享,比如说内存、文件、端口、socket等。既然前面说到系统中的资源是有限的,那么获取这些资源的最小单元体是什么呢,其实就是进程。

    举个例子来说,在linux上面每一个享有资源的个体称为task_struct,实际上和我们说的进程是一样的。我们可以看看task_structlinux 0.11代码)都包括哪些内容,

  1. struct task_struct {  
  2. /* these are hardcoded - don't touch */  
  3.     long state; /* -1 unrunnable, 0 runnable, >0 stopped */  
  4.     long counter;  
  5.     long priority;  
  6.     long signal;  
  7.     struct sigaction sigaction[32];  
  8.     long blocked;   /* bitmap of masked signals */  
  9. /* various fields */  
  10.     int exit_code;  
  11.     unsigned long start_code,end_code,end_data,brk,start_stack;  
  12.     long pid,father,pgrp,session,leader;  
  13.     unsigned short uid,euid,suid;  
  14.     unsigned short gid,egid,sgid;  
  15.     long alarm;  
  16.     long utime,stime,cutime,cstime,start_time;  
  17.     unsigned short used_math;  
  18. /* file system info */  
  19.     int tty;        /* -1 if no tty, so it must be signed */  
  20.     unsigned short umask;  
  21.     struct m_inode * pwd;  
  22.     struct m_inode * root;  
  23.     struct m_inode * executable;  
  24.     unsigned long close_on_exec;  
  25.     struct file * filp[NR_OPEN];  
  26. /* ldt for this task 0 - zero 1 - cs 2 - ds&ss */  
  27.     struct desc_struct ldt[3];  
  28. /* tss for this task */  
  29.     struct tss_struct tss;  
  30. };  

    每一个task都有自己的pid,在系统中资源的分配都是按照pid进行处理的。这也就说明,进程确实是资源分配的主体。

    这时候,可能有朋友会问了,既然task_struct是资源分配的主体,那为什么又出来thread?为什么系统调度的时候是按照thread调度,而不是按照进程调度呢?原因其实很简单,进程之间的数据沟通非常麻烦,因为我们之所以把这些进程分开,不正是希望它们之间不要相互影响嘛。

    假设是两个进程之间数据传输,那么需要如果需要对共享数据进行访问需要哪些步骤呢,

    1)创建共享内存

    2)访问共享内存->系统调用->读取数据

    3)写入共享内存->系统调用->写入数据

    要是写个代码,大家可能就更明白了,

  1. #include <unistd.h>  
  2. #include <stdio.h>  
  3.   
  4. int value = 10;  
  5.   
  6. int main(int argc, char* argv[])  
  7. {  
  8.     int pid = fork();  
  9.     if(!pid){  
  10.         Value = 12;  
  11.         return 0;  
  12.     }  
  13.     printf("value = %d\n", value);  
  14.     return 1;  
  15. }  

    上面的代码是一个创建子进程的代码,我们发现打印的value数值还是10。尽管中间创建了子进程,修改了value的数值,但是我们发现打印下来的数值并没有发生改变,这就说明了不同的进程之间内存上是不共享的。

    那么,如果修改成thread有什么好处呢?其实最大的好处就是每个thread除了享受单独cpu调度的机会,还能共享每个进程下的所有资源。要是调度的单位是进程,那么每个进程只能干一件事情,但是进程之间是需要相互交互数据的,而进程之间的数据都需要系统调用才能应用,这在无形之中就降低了数据的处理效率。


    (2)多核CPU下的多线程

    没有出现多核之前,我们的CPU实际上是按照某种规则对线程依次进行调度的。在某一个特定的时刻,CPU执行的还是某一个特定的线程。然而,现在有了多核CPU,一切变得不一样了,因为在某一时刻很有可能确实是n个任务在n个核上运行。我们可以编写一个简单的open mp测试一下,如果还是一个核,运行的时间就应该是一样的。

  1. #include <omp.h>  
  2. #define MAX_VALUE 10000000  
  3.   
  4. double _test(int value)  
  5. {  
  6.     int index;  
  7.     double result;  
  8.   
  9.     result = 0.0;  
  10.     for(index = value + 1; index < MAX_VALUE; index +=2 )  
  11.         result += 1.0 / index;  
  12.   
  13.     return result;  
  14. }  
  15.   
  16. void test()  
  17. {  
  18.     int index;  
  19.     int time1;  
  20.     int time2;  
  21.     double value1,value2;  
  22.     double result[2];  
  23.   
  24.     time1 = 0;  
  25.     time2 = 0;  
  26.   
  27.     value1 = 0.0;  
  28.     time1 = GetTickCount();  
  29.     for(index = 1; index < MAX_VALUE; index ++)  
  30.         value1 += 1.0 / index;  
  31.   
  32.     time1 = GetTickCount() - time1;  
  33.   
  34.     value2 = 0.0;  
  35.     memset(result , 0, sizeof(double) * 2);  
  36.     time2 = GetTickCount();  
  37.   
  38. #pragma omp parallel for  
  39.     for(index = 0; index < 2; index++)  
  40.         result[index] = _test(index);  
  41.   
  42.     value2 = result[0] + result[1];  
  43.     time2 = GetTickCount() - time2;  
  44.   
  45.     printf("time1 = %d,time2 = %d\n",time1,time2);  
  46.     return;  
  47. }  

    (3)多线程编程

为什么要多线程编程呢?这其中的原因很多,我们可以举例解决

    1)有的是为了提高运行的速度,比如多核cpu下的多线程

    2)有的是为了提高资源的利用率,比如在网络环境下下载资源时,时延常常很高,我们可以通过不同的thread从不同的地方获取资源,这样可以提高效率

    3)有的为了提供更好的服务,比如说是服务器

    4)其他需要多线程编程的地方等等


2. 多线程的那点儿事(之数据同步)

多线程创建其实十分简单,在windows系统下面有很多函数可以创建多线程,比如说_beginthread。我们就可以利用它为我们编写一段简单的多线程代码,

  1. #include <windows.h>  
  2. #include <process.h>  
  3. #include <stdio.h>  
  4.   
  5. unsigned int value = 0;  
  6.   
  7. void print(void* argv)  
  8. {  
  9.     while(1){  
  10.         printf("&value = %x, value = %d\n", &value, value);  
  11.         value ++;  
  12.         Sleep(1000);  
  13.     }  
  14. }  
  15.   
  16. int main()  
  17. {  
  18.     _beginthread( print, 0, NULL );  
  19.     _beginthread( print, 0, NULL);  
  20.   
  21.     while(1)   
  22.         Sleep(0);  
  23.   
  24.     return 1;  
  25. }  

    注意,在VC上面编译的时候,需要打开/MD开关。具体操作为,【project->setting->c/c++->CategoryCode Generation->Use run-time library->Debug Multithreaded】即可。

    通过上面的示例,我们看到作为共享变量的value事实上是可以被所有的线程访问的。这就是线程数据同步的最大优势——方便,直接。因为线程之间除了堆栈空间不一样之外,代码段和数据段都是在一个空间里面的。所以,线程想访问公共数据,就可以访问公共数据,没有任何的限制。

    当然,事物都有其两面性。这种对公共资源的访问模式也会导致一些问题。什么问题呢?我们看了就知道了。

    现在假设有一个池塘,我们雇两个人来喂鱼。两个人不停地对池塘里面的鱼进行喂食。我们规定在一个人喂鱼的时候,另外一个人不需要再喂鱼,否则鱼一次喂两回就要撑死了。为此,我们安装了一个牌子作为警示。如果一个人在喂鱼,他会把牌子设置为FALSE,那么另外一个人看到这个牌子,就不会继续喂鱼了。等到这个人喂完后,他再把牌子继续设置为TRUE

    如果我们需要把这个故事写成代码,那么怎么写呢?朋友们试试看,

  1. while(1){  
  2.     if( flag == true){  
  3.         flag = false;  
  4.         do_give_fish_food();  
  5.         flag = true;  
  6.     }  
  7.   
  8.     Sleep(0);  
  9. }  

    上面的代码看上去没有问题了,但是大家看看代码的汇编代码,看看是不是存在隐患。因为还会出现两个人同时喂食的情况,

  1. 23:       while(1){  
  2. 004010E8   mov         eax,1  
  3. 004010ED   test        eax,eax  
  4. 004010EF   je          do_action+56h (00401126)  
  5. 24:           if( flag == true){  
  6. 004010F1   cmp         dword ptr [flag (00433e04)],1  
  7. 004010F8   jne         do_action+43h (00401113)  
  8. 25:               flag = false;  
  9. 004010FA   mov         dword ptr [flag (00433e04)],0  
  10. 26:               do_give_fish_food();  
  11. 00401104   call        @ILT+15(do_give_fish_food) (00401014)  
  12. 27:               flag = true;  
  13. 00401109   mov         dword ptr [flag (00433e04)],1  
  14. 28:           }  
  15. 29:  
  16. 30:           Sleep(0);  
  17. 00401113   mov         esi,esp  
  18. 00401115   push        0  
  19. 00401117   call        dword ptr [__imp__Sleep@4 (004361c4)]  
  20. 0040111D   cmp         esi,esp  
  21. 0040111F   call        __chkesp (004011e0)  
  22. 31:       }  
  23. 00401124   jmp         do_action+18h (004010e8)  
  24. 32:   }  

    我们此时假设有两个线程ab在不停地进行判断和喂食操作。设置当前flag = true,此时线程a执行到004010F8处时,判断鱼还没有喂食,正准备执行指令004010F8,但是还没有来得及对falg进行设置,此时出现了线程调度。线程b运行到004010F8时,发现当前没有人喂食,所以执行喂食操作。等到b线程喂食结束,运行到00401113的时候,此时又出现了调度。线程a有继续运行,因为之前已经判断了当前还没有喂食,所以线程a继续进行了喂食了操作。所以,可怜的鱼,这一次就连续经历了两次喂食操作,估计有一部分鱼要撑死了。

    当然鱼在这里之所以会出现撑死的情况,主要是因为line 24line 25之间出现了系统调度。所以,我们在编写程序的时候必须有一个牢固的思想意识,如果缺少必须要的手段,程序可以任何时刻任何地点被调度,那此时公共数据的计算就会出现错误。

    那么有没有方法避免这种情况的发生呢?当然有。朋友们可以继续关注下面的博客。


3.  多线程的那点事儿(之数据互斥)

  在多线程存在的环境中,除了堆栈中的临时数据之外,所有的数据都是共享的。如果我们需要线程之间正确地运行,那么务必需要保证公共数据的执行和计算是正确的。简单一点说,就是保证数据在执行的时候必须是互斥的。否则,如果两个或者多个线程在同一时刻对数据进行了操作,那么后果是不可想象的。

    也许有的朋友会说,不光数据需要保护,代码也需要保护。提出这个观点的朋友只看到了数据访问互斥的表象。在程序的运行空间里面,什么最重要的呢?代码吗?当然不是。代码只是为了数据的访问存在的。数据才是我们一切工作的出发点和落脚点。

    那么,有什么办法可以保证在某一时刻只有一个线程对数据进行操作呢?四个基本方法:

    (1)关中断

    (2)数学互斥方法

    (3)操作系统提供的互斥方法

    (4)cpu原子操作

    为了让大家可以对这四种方法有详细的认识,我们可以进行详细的介绍。

  

    (1)关中断

    要让数据在某一时刻只被一个线程访问,方法之一就是停止线程调度就可以了。那么怎样停止线程调度呢?那么关掉时钟中断就可以了啊。在X86里面的确存在这样的两个指令,

  1. #include <stdio.h>  
  2.   
  3. int main()  
  4. {  
  5.     __asm{  
  6.         cli  
  7.         sti  
  8.     }  
  9.     return 1;  
  10. }  

    其中cli是关中断,sti是开中断。这段代码没有什么问题,可以编过,当然也可以生成执行文件。但是在执行的时候会出现一个异常告警:Unhandled exception in test.exe: 0xC0000096:  Privileged Instruction。告警已经说的很清楚了,这是一个特权指令。只有系统或者内核本身才可以使用这个指令。

    不过,大家也可以想象一下。因为平常我们编写的程序都是应用级别的程序,要是每个程序都是用这些代码,那不乱了套了。比如说,你不小心安装一个低质量的软件,说不定什么时候把你的中断关了,这样你的网络就断了,你的输入就没有回应了,你的音乐什么都没有了,这样的环境你受的了吗?应用层的软件是千差万别的,软件的水平也是参差不齐的,所以系统不可能相信任何一个私有软件,它相信的只是它自己。

 

    (2)数学方法

    假设有两个线程(a、b)正要对一个共享数据进行访问,那么怎么做到他们之间的互斥的呢?其实我们可以这么做,

  1. unsigned int flag[2] = {0};  
  2. unsigned int turn = 0;  
  3.   
  4. void process(unsigned int index)  
  5. {  
  6.     flag[index] = 1;  
  7.     turn =  index;  
  8.   
  9.     while(flag[1 - index] && (turn ==  index));  
  10.     do_something();  
  11.     flag[index] = 0;  
  12. }  

    其实,学过操作系统的朋友都知道,上面的算法其实就是Peterson算法,可惜它只能用于两个线程的数据互斥。当然,这个算法还可以推广到更多线程之间的互斥,那就是bakery算法。但是数学算法有两个缺点:

    a)占有空间多,两个线程就要flag占两个单位空间,那么n个线程就要n个flag空间,

    b)代码编写复杂,考虑的情况比较复杂

 

    (3)系统提供的互斥算法

    系统提供的互斥算法其实是我们平时开发中用的最多的互斥工具。就拿windows来说,关于互斥的工具就有临界区、互斥量、信号量等等。这类算法有一个特点,那就是都是依据系统提高的互斥资源,那么系统又是怎么完成这些功能的呢?其实也不难。

    系统加锁过程,

  1. void Lock(HANDLE hLock)  
  2. {  
  3.     __asm {cli};  
  4.   
  5.     while(1){  
  6.         if(/* 锁可用*/){  
  7.             /* 设定标志,表明当前锁已被占用 */  
  8.             __asm {sti};  
  9.             return;  
  10.         }  
  11.   
  12.         __asm{sti};  
  13.         schedule();  
  14.         __asm{cli};  
  15.     }  
  16. }  

    系统解锁过程,

  1. void UnLock(HANDLE hLock)  
  2. {  
  3.     __asm {cli};  
  4.     /* 设定标志, 当前锁可用 */  
  5.     __asm{sti};  
  6. }  

    上面其实讨论的就是一种最简单的系统锁情况。中间没有涉及到就绪线程的压入和弹出过程,没有涉及到资源个数的问题,所以不是很复杂。朋友们仔细看看,应该都可以明白代码表达的是什么意思。

 

    (4)CPU的原子操作
    因为在多线程操作当中,有很大一部分是比较、自增、自减等简单操作。因为需要互斥的代码很少,所以使用互斥量、信号量并不合算。因此,CPU厂商为了开发的方便,把一些常用的指令设计成了原子指令,在windows上面也被称为原子锁,常用的原子操作函数有

  1. InterLockedAdd  
  2.   
  3. InterLockedExchange  
  4.   
  5. InterLockedCompareExchange  
  6.   
  7. InterLockedIncrement  
  8.   
  9. InterLockedDecrement  
  10.   
  11. InterLockedAnd  
  12.   
  13. InterLockedOr 

4.

多线程的那点儿事(之自旋锁)

 自旋锁是SMP中经常使用到的一个锁。所谓的smp,就是对称多处理器的意思。在工业用的pcb板上面,特别是服务器上面,一个pcb板有多个cpu是很正常的事情。这些cpu相互之间是独立运行的,每一个cpu均有自己的调度队列。然而,这些cpu在内存空间上是共享的。举个例子说,假设有一个数据value = 10,那么这个数据可以被所有的cpu访问。这就是共享内存的本质意义。

    我们可以看一段Linux 下的的自旋锁代码(kernel 2.6.23,asm-i386/spinlock.h),就可有清晰的认识了,

  1. static inline void __raw_spin_lock(raw_spinlock_t *lock)  
  2. {  
  3.     asm volatile("\n1:\t"  
  4.              LOCK_PREFIX " ; decb %0\n\t"  
  5.              "jns 3f\n"  
  6.              "2:\t"  
  7.              "rep;nop\n\t"  
  8.              "cmpb $0,%0\n\t"  
  9.              "jle 2b\n\t"  
  10.              "jmp 1b\n"  
  11.              "3:\n\t"  
  12.              : "+m" (lock->slock) : : "memory");  
  13. }  
上面这段代码是怎么做到自旋锁的呢?我们可以一句一句看看,


line  4: 对lock->slock自减,这个操作是互斥的,LOCK_PREFIX保证了此刻只能有一个CPU访问内存
line  5: 判断lock->slock是否为非负数,如果是跳转到3,即获得自旋锁
line  6: 位置符
line  7: lock->slock此时为负数,说明已经被其他cpu抢占了,cpu休息一会,相当于pause指令
line  8: 继续将lock->slock和0比较,
line  9: 判断lock->slock是否小于等于0,如果判断为真,跳转到2,继续休息
line 10: 此时lock->slock已经大于0,可以继续尝试抢占了,跳转到1
line 11: 位置符 
  
    上面的操作,除了第4句是cpu互斥操作,其他都不是。所以,我们发现,在cpu之间寻求互斥访问的时候,在某一时刻只有一个内存访问权限。所以,如果其他的cpu之间没有获得访问权限,就会不断地查看当前是否可以再次申请自旋锁了。这个过程中间不会停歇,除非获得访问的权限为止。


总结:
   1)在smp上自旋锁是多cpu互斥访问的基础
   2)因为自旋锁是自旋等待的,所以处于临界区的代码应尽可能短
   3)上面的LOCK_PREFIX,在x86下面其实就是“lock”,gcc下可以编过,朋友们可以自己试试


5.

多线程的那点儿事(之windows锁)

在windows系统中,系统本身为我们提供了很多锁。通过这些锁的使用,一方面可以加强我们对锁的认识,另外一方面可以提高代码的性能和健壮性。常用的锁以下四种:临界区,互斥量,信号量,event。

    

    (1)临界区

    临界区是最简单的一种锁。基本的临界区操作有,

  1. InitializeCriticalSection  
  2. EnterCriticalSection  
  3. LeaveCriticalSection  
  4. DeleteCriticalSection  
    如果想要对数据进行互斥操作的话,也很简单,这样做就可以了,
  1. EnterCriticalSection(/*...*/)  
  2.     do_something();  
  3. LeaveCriticalSection(/*...*/)  

     (2)互斥锁
    互斥锁也是一种锁。和临界区不同的是,它可以被不同进程使用,因为它有名字。同时,获取锁和释放锁的线程必须是同一个线程。常用的互斥锁操作有
  1. CreateMutex  
  2. OpenMutex  
  3. ReleaseMutex  
    那么,怎么用互斥锁进行数据的访问呢,其实不难。
  1. WaitForSingleObject(/*...*/);  
  2.     do_something();  
  3. ReleaseMutex(/*...*/);  

     (3)信号量
    信号量是使用的最多的一种锁结果,也是最方便的一种锁。围绕着信号量,人们提出了很多数据互斥访问的方案,pv操作就是其中的一种。如果说互斥锁只能对单个资源进行保护,那么信号量可以对多个资源进行保护。同时信号量在解锁的时候,可以被另外一个thread进行解锁操作。目前,常用的信号量操作有,
  1. CreateSemaphore  
  2. OpenSemaphore  
  3. ReleaseSemaphore  
    信号量的使用和互斥锁差不多。关键是信号量在初始化的时候需要明确当前资源的数量和信号量的初始状态是什么,
  1. WaitForSingleObject(/*...*/);  
  2.     do_something();  
  3. ReleaseSemaphore(/*...*/);  

     (4)event对象
    event对象是windows下面很有趣的一种锁结果。从某种意义上说,它和互斥锁很相近,但是又不一样。因为在thread获得锁的使用权之前,常常需要main线程调用SetEvent设置一把才可以。关键是,在thread结束之前,我们也不清楚当前thread获得event之后执行到哪了。所以使用起来,要特别小心。常用的event操作有,
  1. CreateEvent  
  2. OpenEvent  
  3. PulseEvent  
  4. ResetEvent  
  5. SetEvent  
    我们对event的使用习惯于分成main thread和normal thread使用。main thread负责event的设置和操作,而normal thread负责event的等待操作。在CreateEvent的时候,要务必考虑清楚event的初始状态和基本属性。
    对于main thread,应该这么做,
  1. CreateEvent(/*...*/);  
  2. SetEvent(/*...*/);  
  3. WaitForMultiObjects(hThread, /*...*/);  
  4. CloseHandle(/*...*/);  

    对于normal thread来说,操作比较简单,

  1. while(1){  
  2.     WaitForSingleObject(/*...*/);  
  3.   
  4.     /*...*/  
  5. }  

总结:
    (1)关于 临界区互斥区信号量event在msdn上均有示例代码
    (2)一般来说,使用频率上信号量 > 互斥区 > 临界区 > 事件对象
    (3)信号量可以实现其他三种锁的功能,学习上应有所侧重
    (4)纸上得来终觉浅,多实践才能掌握它们之间的区别



6.

多线程的那点儿事(之C++锁)

  编写程序不容易,编写多线程的程序更不容易。相信编写过多线程的程序都应该有这样的一个痛苦过程,什么样的情况呢?朋友们应该看一下代码就明白了,
  1. void data_process()  
  2. {  
  3.     EnterCriticalSection();  
  4.     
  5.     if(/* error happens */)  
  6.     {  
  7.         LeaveCriticalSection();  
  8.         return;  
  9.     }  
  10.   
  11.     if(/* other error happens */)  
  12.     {  
  13.         return;  
  14.     }  
  15.   
  16.     LeaveCriticalSection();  
  17. }  
    上面的代码说明了一种情形。这种多线程的互斥情况在代码编写过程中是经常遇到的。所以,每次对共享数据进行操作时,都需要对数据进行EnterCriticalSection和LeaveCriticalSection的操作。但是,这中间也不是一帆风顺的。很有可能你会遇到各种各样的错误。那么,这时候你的程序就需要跳出去了。可能一开始遇到error的时候,你还记得需要退出临界区。但是,如果错误多了,你未必记得还有这个操作了。这一错就完了,别的线程就没有机会获取这个锁了。
    那么,有没有可能利用C++的特性,自动处理这种情况呢?还真有。我们看看下面这个代码,
  1. class CLock  
  2. {  
  3.     CRITICAL_SECTION& cs;  
  4.   
  5. public:  
  6.     CLock(CRITICAL_SECTION& lock):cs(lock){  
  7.         EnterCriticalSection(&cs);  
  8.     }  
  9.   
  10.     ~CLock() {  
  11.         LeaveCriticalSection(&cs);  
  12.     }  
  13. }  
  14.   
  15. class Process  
  16. {  
  17.     CRITICAL_SECTION cs;  
  18.     /* other data */  
  19.   
  20. public:  
  21.     Process(){  
  22.         InitializeCriticalSection(&cs);  
  23.     }  
  24.   
  25.     ~Process() {DeleteCriticalSection(&cs);}  
  26.   
  27.     void data_process(){  
  28.         CLock lock(cs);  
  29.   
  30.         if(/* error happens */){  
  31.             return;  
  32.         }  
  33.   
  34.         return;  
  35.     }  
  36. }  
    C++的一个重要特点就是,不管函数什么时候退出,系统都会自动调用类的析构函数。在Process类的data_process函数中,,函数在开始就创建了一个CLock类。那么,在创建这个类的时候,其实就开始了临界区的pk。那么一旦进入到临界区当中,在error中能不能及时退出临界区呢?此时,c++析构函数的优势出现了。因为不管错误什么时候出现,在函数退出之前,系统都会帮我们善后。什么善后呢?就是系统会调用CLock的析构函数,也就是退出临界区。这样,我们的目的就达到了。
    其实,这就是一个c++的trick。

7.

多线程的那点儿事(之原子锁)

 原子锁是多线程编程中的一个特色。然而,在平时的软件编写中,原子锁的使用并不是很多。这其中原因很多,我想主要有两个方面。第一,关于原子锁这方面的内容介绍的比较少;第二,人们在编程上面习惯于已有的方案,如果没有特别的需求,不过贸然修改已存在的代码。毕竟对很多人来说,不求有功,但求无过。保持当前代码的稳定性还是很重要的。  
    其实,早在《 多线程数据互斥》这篇博客中,我们就已经介绍过原子锁。本篇博客主要讨论的就是原子锁怎么使用。中间的一些用法只是我个人的一些经验,希望能够抛砖引玉,多听听大家的想法。

    (1)查找函数中原子锁    

    在一些函数当中,有的时候我们需要对满足某种特性的数据进行查找。在传统的单核CPU上,优化的空间比较有限。但是,现在多核CPU已经成了主流配置。所以我们完全可以把这些查找工作分成几个子函数分在几个核上面并行运算。但是,这中间就会涉及到一个问题,那就是对公共数据的访问。传统的访问方式,应该是这样的,

  1. unsigned int count = 0;  
  2.   
  3. int find_data_process()  
  4. {  
  5.     if(/* data meets our standards */){  
  6.          EnterCriticalSection(&cs);  
  7.          count ++;  
  8.          LeaveCriticalSection(&cs);           
  9.     }  
  10. }  

    我们看到代码中间使用到了锁,那么势必会涉及到系统调用和函数调度。所以,在执行效率上会大打折扣。那么如果使用原子锁呢?
  1. unsigned int count = 0;  
  2.   
  3. int find_data_process()  
  4. {  
  5.     if(/* data meets our standards */){  
  6.         InterLockedIncrement(&count);  
  7.     }  
  8. }  

    有兴趣的朋友可以做这样一道题目,查看0~0xFFFFFFFF上有多少数可以被3整除?大家也可以验证一下用原子锁代替临界区之后,代码的效率究竟可以提高多少。关于多核多线程的编程,朋友们可以参考《多线程基础篇》这篇博客。


    (2)代码段中的原子锁
    上面的范例只是介绍了统计功能中的原子锁。那么怎么用原子锁代替传统的系统锁呢?比如说,假设原来的数据访问是这样的,

  1. void data_process()  
  2. {  
  3.     EnterCriticalSection(&cs);  
  4.     do_something();  
  5.     LeaveCriticalSection(&cs);     
  6. }  
    如果改成原子锁呢,会是什么样的呢?
  1. unsigned int lock = 0;  
  2.   
  3. void data_process()  
  4. {  
  5.     while(1 == InterLockedCompareExchange(&lock, 1, 0));  
  6.     do_something();  
  7.     lock = 0;      
  8. }  

    这里用原子锁代替普通的系统锁,完成的功能其实是一样的。那么这中间有什么区别呢?其实,关键要看do_something要执行多久。打个比方来说,现在我们去买包子,但是买包子的人很多。那怎么办呢?有两个选择,如果卖包子的人手脚麻利,服务一个顾客只要10秒钟,那么即使前面排队的有50个人,我们只要等7、8分钟就可以,这点等的时间还是值得的;但是如果不幸这个卖包子的老板服务一个顾客要1分钟,那就悲催了,假使前面有50个人,那我们就要等50多分钟了。50分钟对我们来说可是不短的一个时间,我们完全可以利用这个时间去买点水果,交交水电费什么的,过了这个时间点再来买包子也不迟。


    和上面的例子一样,忙等的方法就是原子锁,过一会再来的方法就是哪个传统的系统锁。用哪个,就看这个do_something的时间值不值得我们等待了。


8.

多线程的那点儿事(之读写锁)

在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢?
    有,那就是读写锁。

    (1)首先,我们定义一下基本的数据结构。

  1. typedef struct _RWLock  
  2. {  
  3.     int count;  
  4.     int state;  
  5.     HANDLE hRead;  
  6.     HANDLE hWrite;  
  7. }RWLock;     
    同时,为了判断当前的锁是处于读状态,还是写状态,我们要定义一个枚举量,
  1. typedef enum  
  2. {  
  3.     STATE_EMPTY = 0,  
  4.     STATE_READ,  
  5.     STATE_WRITE  
  6. };  
     (2)初始化数据结构
  1. RWLock* create_read_write_lock(HANDLE hRead, HANDLE hWrite)  
  2. {  
  3.     RWLock* pRwLock = NULL;  
  4.   
  5.     assert(NULL != hRead && NULL != hWrite);  
  6.     pRwLock = (RWLock*)malloc(sizeof(RWLock));  
  7.     
  8.     pRwLock->hRead = hRead;  
  9.     pRwLock->hWrite = hWrite;  
  10.     pRwLock->count = 0;  
  11.     pRwLock->state = STATE_EMPTY;  
  12.     return pRwLock;  
  13. }  
     (3)获取读锁
  1. void read_lock(RWLock* pRwLock)  
  2. {  
  3.     assert(NULL != pRwLock);  
  4.       
  5.     WaitForSingleObject(pRwLock->hRead, INFINITE);  
  6.     pRwLock->counnt ++;  
  7.     if(1 == pRwLock->count){  
  8.         WaitForSingleObject(pRwLock->hWrite, INFINITE);  
  9.         pRwLock->state = STATE_READ;  
  10.     }  
  11.     ReleaseMutex(pRwLock->hRead);  
  12. }  
     (4)获取写锁
  1. void write_lock(RWLock* pRwLock)  
  2. {  
  3.     assert(NULL != pRwLock);  
  4.   
  5.     WaitForSingleObject(pRwLock->hWrite, INFINITE);  
  6.     pRwLock->state = STATE_WRITE;  
  7. }  
     (5)释放读写锁
  1. void read_write_unlock(RWLock* pRwLock)  
  2. {  
  3.     assert(NULL != pRwLock);  
  4.   
  5.     if(STATE_READ == pRwLock->state){  
  6.         WaitForSingleObject(pRwLock->hRead, INFINITE);  
  7.         pRwLock->count --;  
  8.         if(0 == pRwLock->count){  
  9.             pRwLock->state = STATE_EMPTY;  
  10.             ReleaseMutex(pRwLock->hWrite);  
  11.         }  
  12.         ReleaseMutex(pRwLock->hRead);  
  13.     }else{  
  14.         pRwLock->state = STATE_EMPTY;  
  15.         ReleaseMutex(pRwLock->hWrite);  
  16.     }  
  17.       
  18.     return;  
  19. }  

文章总结:
    (1)读写锁的优势只有在多读少写、代码段运行时间长这两个条件下才会效率达到最大化;
    (2)任何公共数据的修改都必须在锁里面完成;
    (3)读写锁有自己的应用场所,选择合适的应用环境十分重要;
    (4)编写读写锁很容易出错,朋友们应该多加练习;
    (5)读锁和写锁一定要分开使用,否则达不到效果。