zoukankan      html  css  js  c++  java
  • [转]多线程的那点儿事

    原贴地址: 多线程的那点儿事

    多线程的那点儿事-优先级反转

    优先级反转对于编写应用层的人员来说不大会发生,但是对于操作系统的设计者来说确是一个逃不过去的问题。要知道怎么样处理优先级反转?那么先看看它是怎么发生的。

    (1)调度队列和线程优先级

    在操作系统中,线程的状态有很多种。比如说,线程的状态可能是 suspend、block、ready、die 几种类型。我们把所有的 ready 线程放在一个队列里面,这就构成了一个基本的调度队列。

    我们还知道,为了对所有的线程进行有差别的时间调度,我们对所有的线程分配了优先级。打个比方,调度队列有 32 个线程,每个线程的优先级也是 1 到 32。这些优先级对于线程来说有什么意义呢?那就是,高优先级可以获得较多的时间片运行机会。进一步极端一点说,优先级为 32 可以 32 个基本时间片,那么优先级为 1 的线程只能获得一个时间片的运行机会。

    (2)锁和线程

    在队列调度过程当中,高优先级的线程获得较多的运行机会,而与此对应的低优先级线程运行的机会较少。举个例子来说,现在有 32 个线程,线程的优先级分布在1~32 之间。那么这些程序怎么运行呢,

    线程0x20  优先级32   时间片 32个
    线程0x1F  优先级31   时间片 31个
    线程0x1E  优先级30   时间片 30个
    /* 其他线程 */
    线程0x01  优先级01   时间片 01个
    

    所以如果总的时间片为 ((1 + 32) * (32 / 2) = 528) , 所以一段时间内每个线程都有运行的机会。只不过,各个线程运行的机会不一样而已。但是这一切都因为锁的存在发生了改变。假设现在线程 0x20 和 0x1 都在争取一个锁,而这个锁此时正处在线程 0x01 的运行时间片内,所以线程 0x01 获得了锁。那么线程 0x20 此时只好退出运行队列,静静等待线程 0x1 退出锁了。

    糟糕的还不止这一点,前面我们说过低优先级的线程运行机会较少。所以,线程 0x01 获得运行的机会只是 1/528,即使线程 0x20 退出了队列,那只有 1/496,其中 (496 = (1 + 31) / 2 * 31) 。如果线程 0x01 运行的时间还比较长,那就比较悲催了。线程 0x20 还要等待多长时间才能获得线程 0x01 的锁,那就只有天知道了。此时,原来的优先级也失去了意义,这才是优先级发生反转的真实原因。

    (3)解决方法

    原来制定优先级的目的就是为了让有的程序运行时间长一点,有的程序运行时间短一点。然而,这一切在锁面前从优点变成了缺点。那么解决的办法是什么呢?其实也不难,那就是提高线程0x01的优先级,尽快让线程0x01尽快退出锁。线程0x01和线程0x20交换一下优先级的方法就不错。

    总结:

    (1)优先级反转提醒我们使用锁的代码段应尽量短;

    (2)注意用小锁代替大锁,减少冲突的机会;

    (3)如果锁保护的代码段很短,直接使用原子锁忙等也是不错的一个方法。

    多线程的那点儿事-多核编程

    多核编程并不是最近才兴起的新鲜事物。早在 intel 发布双核 cpu 之前,多核编程已经在业内存在了,只不过那时候是多处理器编程而已。为了实现多核编程,人们开发实现了几种多核编程的标准。open-mp 就是其中的一种。对于open-mp还不太熟悉的朋友,可以参照 维基百科 的相关解释。

    open-mp的格式非常简单,原理也不难。它的基本原理就是创建多个线程,操作系统把这几个线程分到几个核上面同时执行,从而可以达到快速执行代码的目的。比如说,我们可以编写一个简单的例子。

    在编写 open-mp 程序之前,朋友们应该注意下面三点,

    (1) 使用 vs2005 或者以上的版本编写 open-mp 程序;

    (2) 编写程序的时候,选择【Property Pages】->【Configuration Properties】->【c/c++】->【language】->【OpenMp Support】,打开开关;

    (3) 添加 #include <omp.h> 声明。

    首先,我们编写简单的一个打印程序,看看结果。

    #include <omp.h>
    
    void print()
    {
      int i;
    #pragma omp parallel for
      for(i = 0; i < 100; i ++)
      {
        printf("%d
    ", i);
      }
    }
    

    上面这段代码好像也没有什么特别的地方,但是我们可以在printf设一个断点,看看函数调用堆栈,

    openmp.exe!print$omp$1()  Line 14	C++
    vcompd.dll!_vcomp::ParallelRegion::HandlerThreadFunc()  + 0x19c bytes
    vcompd.dll!_vcomp::ParallelRegion::HandlerThreadFunc()  + 0xe0 bytes
    vcompd.dll!_InvokeThreadTeam@12()  + 0x98 bytes
    vcompd.dll!__vcomp_fork()  + 0x1cd bytes
    openmp.exe!print()  Line 11 + 0xe bytes	C++
    openmp.exe!wmain(int argc=1, wchar_t * * argv=0x003b5ba8)  Line 22	C++
    openmp.exe!__tmainCRTStartup()  Line 583 + 0x19 bytes	C
    openmp.exe!wmainCRTStartup()  Line 403	C
    kernel32.dll!7c817077()
    [Frames below may be incorrect and/or missing, no symbols loaded for kernel32.dll]
    

    我们看可以看到函数堆栈和平时看到的一般函数调用确实不一样,但这确实也说明不了什么,不过没有关系,我们可以做一个简单的测试,

    #include "stdafx.h"
    #include <windows.h>
    #include <omp.h>
    
    #define NUMBER 1000000000
    
    int process(int start, int end)
    {
      int total;
    
      total = 0;
      while(start < end){
        total += start %2;
        start ++;
      }
    
      return total;
    }
    
    void test1()
    {
      int i;
      int time;
      struct {
        int start;
        int end;
      }value[] = {
        {0 , NUMBER >> 1},
        {NUMBER >> 1, NUMBER}
      };
      int total[2] = {0};
      int result;
    
      time = GetTickCount();
    
    #pragma omp parallel for
      for(i = 0; i < 2; i ++)
      {
        total[i] = process(value[i].start, value[i].end);
      }
    
      result = total[0] + total[1];
      printf("%d
    ", GetTickCount() - time);
    }
    
    void test2()
    {
      int i;
      int value;
      int total;
    
      total = 0;
      value = GetTickCount();
    
      for(i = 0; i < NUMBER; i++)
      {
        total += i %2;
      }
      printf("%d
    ", GetTickCount()-value);
    }
    
    
    int _tmain(int argc, _TCHAR* argv[])
    {
      test1();
      test2();
      return 0;
    }
    

    test1 和 test2 完成的功能都是一样的,但是明显 test1 要比 test2 花费的时间要少很多,这明显就是多核编程的好处。所以要想实现多核编程最大程度上的并行运算,就必须把运算拆分成n个子运算,并且尽量减少使用锁。

    总结:

    (1) 这篇文章只是介绍性的博客,详细内容可以参考周伟明先生的博客

    (2) 关于多核编程更具体的细节和方法,可以参考《多核计算与程序设计》这本书;

    (3) 编写多核编程需要的条件很简单,cpu支持多核、打开openmp开关、添加omp头文件,剩下的就是多多练习了;

    (4) 并行运算的时候少使用锁,否则效率会大打折扣。

    多线程的那点儿事-多线程数据结构

    要想编写多线程,那就要使用锁。而在软件编写中,数据结构是少不了的。所以,我们在编写多线程的时候,就需要考虑一下如何在数据结构中插入锁。当然,有些数据结构是没有锁的,所以自然这个锁并不一定是必须的。

    比如说,我们编写一个多线程堆栈,应该怎么做呢,

    typedef struct _STACK
    {
        void* pData;
        int maxLen;
        int top;
        void* hLock;
    
        STATUS (*push)(struct _STACK* pStack, void* pData, int size);
        STATUS (*pop)(struct _STACK* pStack, void* pData, int size);
    }STACK;
    

    (1) 初始化操作

    STACK* get_new_stack(void* pData, int len, void* pLock)
    {
        STACK* pStack;
    
        if(NULL == pData || 0 == len)
            return NULL;
    
        pStack = (STACK*)malloc(sizeof(STACK));
        assert(NULL != pStack);
    
        memset(pStack, 0, sizeof(STACK));
        pStack->pData  = pData;
        pStack->maxLen = len;
    
        if(NULL != pLock)
            pStack->hLock = pLock;
    
        return pStack;
    }
    

    (2) 添加数据

    STATUS push(struct _STACK* pStack, void* pData, int size)
    {
      assert(NULL != pStack && NULL != pData);
    
      if(NULL != pStack->hLock)
          WaitForSingleObject((HANDLE)pStack->hLock, INFINITE);
    
      if(pStack->top == pStack->maxLen){
          if(NULL != pStack->hLock)
              ReleaseMutex((HANDLE)pStack->hLock);
    
          return ERROR;
      }
    
      memmove((char*)pStack->pData + size * pStack->top, (char*)pData, size);
      pStack->top ++;
    
      if(NULL != pStack->hLock)
          ReleaseMutex((HANDLE)pStack->hLock);
    
      return OK;
    }
    

    (3) 对(2)进行优化,因为判断的条件比较复杂

    #define STACK_CHECK_LOCK(hLock) 
    do{
        if(hLock)
            WaitForSingleObject((HANDLE)hLock, INFINITE);
    }while(0)
    
    #define STACK_CHECK_UNLOCK(hLock) 
    do{
        if(hLock)
            ReleaseMutex((HANDLE)hLock);
    }while(0)
    

    所以,2的代码可以修改为,

    STATUS push(struct _STACK* pStack, void* pData, int size)
    {
      assert(NULL != pStack && NULL != pData);
    
      STACK_CHECK_LOCK(pStack->hLock);
    
      if(pStack->top == pStack->maxLen){
          STACK_CHECK_UNLOCK(pStack->hLock);
          return ERROR;
      }
    
      memmove((char*)pStack->pData + size * pStack->top, (char*)pData, size);
      pStack->top ++;
    
      STACK_CHECK_UNLOCK(pStack->hLock);
      return OK;
    }
    

    总结:

    (1) 一般来说,比较好的数据结构要兼有多线程和没有多线程两种情况
    (2) 如果需要用其他的锁代替mutex,直接换掉就可以,十分方便

    多线程的那点儿事-无锁链表

    前面,为了使得写操作快速进行,我们定义了顺序锁。但是顺序锁有个缺点,那就是处理的数据不能是指针,否则可能会导致exception。那么有没有办法使得处理的数据包括指针呢?当然要是这个链表没有锁,那就更好了。

    针对这种无锁链表,我们可以初步分析一下,应该怎么设计呢?

    (1)读操作没有锁,那么怎么判断读操作正在进行呢,只能靠标志位了;

    (2)写操作没有锁,那么读操作只能一个线程完成;

    (3)写操作中如果是添加,那么直接加在末尾即可;

    (4)写操作中如果是删除,那么应该先删除数据,然后等到当前没有操作访问删除数据时,释放内存,但是首节点不能删除。

    普通链表的结构为,

    typedef struct _LINK
    {
      int data;
      struct _LINK* next;
    }LINK;
    

    假设此时有32个线程在访问链表,那么可以定义一个全局变量value,每一个bit表示一个thread,读操作怎么进行呢,

    void read_process()
    {
      int index = get_index_from_threadid(GetThreadId());
      InterLockedOr(&value, 1 << index);
      /* read operation */
      InterLockedAnd(&value, ~(1<< index));
    }
    

    那么,写操作怎么进行呢,

    void write_process_add(LINK* pHead, LINK* pLink)
    {
      /* add link to the tail of list */
    }
    
    void write_process_del(LINK* pHead, LINK* pLink)
    {
      delete_link_from_list(pHead, pLink);
      while(1){
          if(0 == value)
              break;
            Sleep(100);
      }
    
      free(pLink);
    }
    

    其中链表的删除操作为,

    /*
    *  From:
    *    ->   a  ->  b -> c -> d
    *
    *  To:
    *        -----------------
    *        |               V
    *    ->  a        b  ->  c ->d
    *
    */
    

    总结:

    (1)这种无锁链表有很多局限:多读少写、注意使用原子操作、不能删除头结点、数据只能添加到尾部、注意删除顺序和方法、读线程个数有限制等等;

    (2)写操作在操作前需要等待所有的读操作,否则有可能发生异常;

    (3)写操作不能被多个线程使用;

    (4)无锁链表应用范围有限,只是特殊情况下的一种方案而已。

    多线程的那点儿事-顺序锁

    在互斥数据访问中有一种多读少写的情况。正对这么一种情形,我们也提出了读写锁的方案。但是呢,这个锁有些缺陷。什么缺陷呢?那就是,这个写锁需要在所有的读锁完成之后才能写。否则的话,写锁需要这么一直等下去。

    那么,有没有什么办法能使得写操作快速一点进行呢?那就是顺序锁。

    typedef struct _SEQUENCE_LOCK
    {
        unsigned int sequence;
        HANDLE hLock;
    
    }SEQUENCE_LOCK;
    

    有了这么一个数据结构之后。那么读锁怎么开始呢,

    unsigned int get_lock_begin(SEQUENCE_LOCK* hSeqLock)
    {
      assert(NULL != hSeqLock);
    
      return hSeqLock->sequence;
    }
    
    int get_lock_retry(SEQUENCE_LOCK* hSeqLock, unsigned int value)
    {
      unsigned int new_value;
      assert(NULL != hSeqLock);
    
      new_value = hSeqLock->sequence;
      return (new_value & 0x1) || (new_value ^ value);
    }
    

    自然写锁也需要修改了,

    void get_write_lock(SEQUENCE_LOCK* hSeqLock)
    {
      assert(NULL != hSeqLock);
    
      WaitForSingleObject(hSeqLock->hLock);
      hSeqLock->sequence ++;
    }
    
    void release_write_lock(SEQUENCE_LOCK* hSeqLock)
    {
      assert(NULL != hSeqLock);
    
      hSeqLock->sequence ++;
      ReleaseMutex(hSeqLock->hLock);
    }
    

    如果应用呢,其实也不难,

    void read_process(SEQUENCE_LOCK* hSeqLock)
    {
      unsigned int sequence;
    
      do{
          sequence = get_lock_begin(hSeqLock);
          /* read operation  */
      }while(get_lock_retry(hSeqLock, sequence));
    }
    
    void write_process(SEQUENCCE_LOCK* hSeqLock)
    {
      get_write_lock(hSeqLock);
      /* write operation */
      release_write_lock(hSeqLock);
    }
    

    总结:

    (1)读锁退出有两个条件,要么写操作正在进行呢,要么没有写锁

    (2)写锁之间需要互斥操作

    (3)互斥操作的数据不能是指针,否则有可能在访问的时候会造成异常,因为有可能边写边读

    (4)顺序锁代替不了读写锁,因为读写锁可以保证所有的数据操作,而顺序锁不行

    多线程的那点儿事-无锁队列

    对于编写多线程的朋友来说,队列具有天生的互斥性。在队列里面,一个负责添加数据,一个负责处理数据。谁也不妨碍谁,谁也离不开谁。所以,队列具有天生的并行性。

    #define MAX_NUMBER 1000L
    #define STATUS int
    #define OK     0
    #define FALSE -1
    
    typedef struct _QUEUE_DATA
    {
        int data[MAX_NUMBER];
        int head;
        int tail;
    }QUEUE_DATA;
    

    此时,一个线程压入数据,操作为push_data,

    STATUS push_data(QUEUE_DATA* pQueue, int data)
    {
        if(NULL == pQueue)
            return ERROR;
    
        if(pQueue->head == ((pQueue->tail) + 1)% MAX_NUMBER)
            return ERROR;
    
        pQueue->data[pQueue->tail] = data;
        pQueue->tail = (pQueue->tail + 1)% MAX_NUMBER;
        return OK;
    }
    

    那么,还有一个线程就负责处理数据pop_data,

    STATUS pop_data(QUEUE_DATA* pQueue, int* pData)
    {
        if(NULL == pQueue || NULL == pData)
            return ERROR;
    
        if(pQueue->head == pQueue->tail)
            return ERROR;
    
        *pData = pQueue->data[pQueue->head];
        pQueue->head = (pQueue->head + 1)% MAX_NUMBER;
        return OK;
    }
    

    总结:

    (1)队列只适合两个线程并行使用,一个压入数据,一个弹出数据
    (2)队列是没有锁的并行,没有死锁的危险
    (3)队列中head和tail只有在计算结束之前的时候才能进行自增运算

    多线程的那点儿事-多线程调试

    软件调试是我们软件开发过程中的重要一课。在前面,我们也讨论过程序调试,比如说这里。今天,我们还可以就软件调试多讲一些内容。比如说条件断点,数据断点,多线程断点等等。

    #include <stdio.h>
    int value = 0;
    
    void test()
    {
      int total;
      int index;
    
      total = 0;
      for(index = 0; index < 100; index ++)
        total += index * index;
    
      value = total;
      return ;
    }
    
    int main()
    {
      test();
      return 1;
    }
    

    (1) 数据断点

    所谓数据断点,就是全局变量或者函数中的数计算的过程中,如果数据值本身发生了改变,就会触发断点。这里的数据有两种,一个是全局数据,一个函数内部的数据。

    以全局数据value为例:

    a)按F10,运行程序,获取value的地址;

    b)Alt+F9,选择【DATA】->【Advanced】;

    c)在【Expression】中输入DW(0x0043178),【ok】回车;

    d)F5继续运行程序,则程序会在value发生改变的时候停住。

    以局部数据total为例,

    a)按F10,运行程序,获取value的地址;

    b)Alt+F9,选择【DATA】->【Advanced】;

    c)在【Expression】中输入total,在【Function】输入 test,【ok】回车;

    d)F5 继续运行程序,则程序同样会在total发生改变的时候停住。

    (2)条件断点

    条件断点和数据断点差不多。只不过,数据断点在数据发生改变的时候就会断住,而条件断点只有在满足一定的条件下才会有断住。比如说,我们可以让test子程序在index==5的时候断住。

    a)按F10,运行程序,获取value的地址;

    b)Alt+F9,选择【DATA】->【Advanced】;

    c)在【Expression】中输入index==5,在【Function】输入test,【ok】回车;

    d)F5继续运行程序,则程序同样会在index==5的时候停住。

    (3) 多线程调试

    在VC上面对多程序的调试比较简单。如果想要对程序进行调试的话,首先F10,开始运行程序。其次,我们需要等线程创建之后才能设置断点,不然我们看到的程序只有main函数一个thread。

    a)单击【Debug】,选择【threads】,那么我们就可以开始多线程调试了;

    b)如果需要对某一个thread挂起,单击对应的thread,选择【suspend】即可;

    c)如果需要对某一个thread重新调度,单击对应的thread,选择【resume】即可;

    d)如果需要查看特定thread的堆栈,那么选择那个thread,然后【Set Focus】,关闭threads对话框,在堆栈窗口中即可看到;

    e)如果某个线程被挂住,那么此时所有的线程都挂住了,如果你step运行,所有的threads都会参与运行;

    f)如果需要对某一个thread进行调试,那么需要对其他的thread进行suspend处理 。

    总结

    1)看内存、看堆栈、条件断点、数据断点需要综合使用,

    2)编程越早调试,越好,

    3)先编写好单线程程序,再编写好多线程程序,

    4)对于多线程来说,模块设计 > 编程预防 > 调试 > 事后补救。

    多线程的那点儿事-避免死锁

    预防死锁的注意事项:

    (1)在编写多线程程序之前,首先编写正确的程序,然后再移植到多线程

    (2)时刻检查自己写的程序有没有在跳出时忘记释放锁

    (3)如果自己的模块可能重复使用一个锁,建议使用嵌套锁

    (4)对于某些锁代码,不要临时重新编写,建议使用库里面的锁,或者自己曾经编写的锁

    (5)如果某项业务需要获取多个锁,必须保证锁的按某种顺序获取,否则必定死锁
    (6)编写简单的测试用例,验证有没有死锁

    (7)编写验证死锁的程序,从源头避免死锁

    首先,定义基本的数据结构和宏,

    typedef struct _LOCK_INFO
    {
        char lockName[32];
        HANDLE hLock;
    }LOCK_INFO:
    
    typedef struct _THREAD_LOCK
    {
        int threadId;
        LOCK_INFO* pLockInfo[32];
    }THREAD_LOCK;
    
    #define CRITICAL_SECTION_TYPE 1
    #define MUTEX_LOCK_TYPE       2
    #define SEMAPHORE_LOCK_TYPE   3
    #define NORMAL_LOCK_TYPE      4
    
    #define WaitForSingleObject(a, b) 
            WaitForSingleObject_stub((void*)a, NORMAL_LOCK_TYPE)
    
    #define EnterCriticalSection(a) 
            WaitForSingleObject_stub((void*)a, CRITICAL_SECTION_TYPE)
    
    #define ReleaseMutex(a) 
            ReleaseLock_stub((void*)a, MUTEX_LOCK_TYPE))
    
    #define ReleaseSemaphore(a, b, c) 
            ReleaseLock_stub((void*)a, SEMAPHORE_LOCK_TYPE))
    
    #define LeaveCriticalSection(a) 
            ReleaseLock_stub((void*)a, CRITICAL_SECTION_TYPE))
    

    然后,改写锁的申请函数,

    void WaitForSingleObject_stub(void* hLock, int type)
    {
      /* step 1 */
      WaitForSingleObject(hDbgLock);
      /* check if lock loops arounds threads */
      ReleaseMutex(hDbgLock);
    
      /* step 2 */
      if(NORMAL_LOCK_TYPE == type)
          WaitForSingleObject((HANDLE)hLock, INFINITE);
      else if(CRITICAL_SECTION_TYPE == type)
          EnterCriticalSection((LPCRITICAL_SECTION)hLock);
      else
          assert(0);
    
      /* step 3 */
      WaitForSingleObject(hDbgLock);
      /* add lock to specified threadid list */
      ReleaseMutex(hDbgLock);
    }
    

    最后,需要改写锁的释放函数。

    void ReleaseLock_stub(void* hLock, int type)
    {
      /* step 1 */
      WaitForSingleObject(hDbgLock);
      /* remove lock from specified threadid list */
      ReleaseMutex(hDbgLock);
    
      /* step 2 */
      if(MUTEX_LOCK_TYPE))== type)
          ReleaseMutex(HANDLE)hLock);
      else if(SEMAPHORE_LOCK_TYPE == type)
          ReleaseSemaphore((HANDLE)hLock, 1, NULL);
      else if(CRITICAL_SECTION_TYPE == type)
          LeaveCriticalSection((LPCRITICAL_SECTION)hLock);
      assert(0);
    }
    

    多线程的那点儿事-生产者-消费者

    生产者-消费者是很有意思的一种算法。它的存在主要是两个目的,第一就是满足生产者对资源的不断创造;第二就是满足消费者对资源的不断索取。当然,因为空间是有限的,所以资源既不能无限存储,也不能无限索取。

    生产者的算法,

    ···c++
    WaitForSingleObject(hEmpty, INFINITE);
    WaitForSingleObject(hMutex, INIFINITE);
    /* produce new resources. */
    ReleaseMutex(hMutex);
    ReleaseSemaphore(hFull, 1, NULL);
    ···

    消费者的算法,

      WaitForSingleObject(hFull, INFINITE);
      WaitForSingleObject(hMutex, INIFINITE);
      /* consume old resources */
      ReleaseMutex(hMutex);
      ReleaseSemaphore(hEmpty, 1, NULL);
    

    那么,有的朋友可能会说了,这么一个生产者-消费者算法有什么作用呢。我们可以看看它在多线程通信方面是怎么发挥作用的?首先我们定义一个数据结构,

    typedef struct _MESSAGE_QUEUE
    {
      int threadId;
      int msgType[MAX_NUMBER];
      int count;
      HANDLE hFull;
      HANDLE hEmpty;
      HANDLE hMutex;
    }MESSAGE_QUEUE;
    

    那么,此时如果我们需要对一个线程发送消息,该怎么发送呢,其实很简单。我们完全可以把它看成是一个生产者的操作。

    void send_mseesge(int threadId, MESSAGE_QUEUE* pQueue, int msg)
    {
      assert(NULL != pQueue);
    
      if(threadId != pQueue->threadId)
          return;
    
      WaitForSingleObject(pQueue->hEmpty, INFINITE);
      WaitForSingleObject(pQueue->hMutex, INFINITE);
      pQueue->msgType[pQueue->count ++] = msg;
      ReleaseMutex(pQueue->hMutex);
      ReleaseSemaphore(pQueue->hFull, 1, NULL);
    }
    

    既然前面说到发消息,那么线程自身就要对这些消息进行处理了。

    void get_message(MESSAGE_QUEUE* pQueue, int* msg)
    {
        assert(NULL != pQueue && NULL != msg);
    
        WaitForSingleObject(pQueue->hFull, INFINITE);
        WaitForSingleObject(pQueue->hMutex, INFINITE);
        *msg = pQueue->msgType[pQueue->count --];
        ReleaseMutex(pQueue->hMutex);
        ReleaseSemaphore(pQueue->hEmpty, 1, NULL);
    }
    

    总结:

    (1)生产者-消费者只能使用semphore作为锁

    (2)编写代码的时候需要判断hFull和hEmpty的次序

    (3)掌握生产者-消费者的基本算法很重要,但更重要的是自己的实践

    多线程的那点儿事-嵌套锁

    嵌套锁这个概念,主要是为了根据编程中的一种情形引申出来的。什么情况呢,我们可以具体说明一下。
    假设你在处理一个公共函数的时候,因为中间涉及公共数据,所以你加了一个锁。
    但是,有一点比较悲哀。这个公共函数自身也加了一个锁,而且和你加的锁是一样的。
    所以,除非你的使用的是信号量,要不然你的程序一辈子也获取不了这个锁。

    HANDLE hLock;
    
    void sub_func()
    {
        /*...*/
        WaitForSingleObject(hLock, INFINITE);
        do_something();
        ReleaseMutex(hLock);
        /*...*/
    }
    
    void data_process()
    {
        /*...*/
        WaitForSingleObject(hLock, INFINITE);
        sub_func();
        ReleaseMutex(hLock);
        /*...*/
    }
    

    出现这种情况的原因很多。很重要的一个方面是因为软件的各个模块是不同的人负责的。所以本质上说,我们根本无法确定别人使用了什么样的锁。你也无权不让别人使用某个锁。所以,遇到这种情况,只好靠你自己了。嵌套锁就是不错的一个解决办法。

    (1)嵌套锁的数据结构

    typedef struct _NestLock
    {
        int threadId;
        int count;
        HANDLE hLock;
    }NestLock;
    
    NestLock* create_nest_lock(HANLDE hLock)
    {
      NestLock* hNestLock = (NestLock*)malloc(sizeof(NestLock));
      assert(NULL != hNestLock);
    
      hNestLock->threadId = hNestLock->count = 0;
      hNestLock->hLock = hLock;
      return hNestLock;
    }
    

    (2)申请嵌套锁

    void get_nest_lock(NestLock* hNestLock)
    {
        assert(NULL != hNestLock);
    
        if(hNestLock->threadId == GetThreadId())
        {
            hNestLock->count ++;
        }else{
            WaitForSingleObject(hNestLock->hLock);
            hNestLock->count = 1;
            hNestLock->threadId = GetThreadId();
        }
    }
    

    (3)释放锁

    void release_nest_lock(NestLock* hNestLock)
    {
        assert(NULL != hNestLock);
        assert(GetThreadId() == hNestLock->threadId);
    
        hNestLock->count --;
        if(0 == hNestLock->count){
            hNestLock->threadId = 0;
            ReleaseMutex(hNestLock->hLock);
        }
    }
    

    总结

    (1)嵌套锁与其说是新的锁类型,不如说是统计锁而已

    (2)嵌套锁和普通的锁一样,使用十分方便

    (3)嵌套锁也有缺点,它给我们的锁检测带来了麻烦

    多线程的那点儿事-读写锁

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

    有,那就是读写锁。

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

    typedef struct _RWLock
    {
        int count;
        int state;
        HANDLE hRead;
        HANDLE hWrite;
    }RWLock;
    

    同时,为了判断当前的锁是处于读状态,还是写状态,我们要定义一个枚举量,

    typedef enum
    {
        STATE_EMPTY = 0,
        STATE_READ,
        STATE_WRITE
    };
    

    (2)初始化数据结构

    WLock* create_read_write_lock(HANDLE hRead, HANDLE hWrite)
    {
        RWLock* pRwLock = NULL;
    
        assert(NULL != hRead && NULL != hWrite);
        pRwLock = (RWLock*)malloc(sizeof(RWLock));
    
        pRwLock->hRead = hRead;
        pRwLock->hWrite = hWrite;
        pRwLock->count = 0;
        pRwLock->state = STATE_EMPTY;
        return pRwLock;
    }
    

    (3)获取读锁

    void read_lock(RWLock* pRwLock)
    {
        assert(NULL != pRwLock);
    
        WaitForSingleObject(pRwLock->hRead, INFINITE);
        pRwLock->counnt ++;
        if(1 == pRwLock->count){
            WaitForSingleObject(pRwLock->hWrite, INFINITE);
            pRwLock->state = STATE_READ;
        }
        ReleaseMutex(pRwLock->hRead);
    }
    

    (4)获取写锁

    void write_lock(RWLock* pRwLock)
    {
        assert(NULL != pRwLock);
    
        WaitForSingleObject(pRwLock->hWrite, INFINITE);
        pRwLock->state = STATE_WRITE;
    }
    

    (5)释放读写锁

    void read_write_unlock(RWLock* pRwLock)
    {
        assert(NULL != pRwLock);
    
        if(STATE_READ == pRwLock->state){
            WaitForSingleObject(pRwLock->hRead, INFINITE);
            pRwLock->count --;
            if(0 == pRwLock->count){
                pRwLock->state = STATE_EMPTY;
                ReleaseMutex(pRwLock->hWrite);
            }
            ReleaseMutex(pRwLock->hRead);
        }else{
            pRwLock->state = STATE_EMPTY;
            ReleaseMutex(pRwLock->hWrite);
        }
    
        return;
    }
    

    总结

    (1)读写锁的优势只有在多读少写、代码段运行时间长这两个条件下才会效率达到最大化;

    (2)任何公共数据的修改都必须在锁里面完成;

    (3)读写锁有自己的应用场所,选择合适的应用环境十分重要;

    (4)编写读写锁很容易出错,朋友们应该多加练习;

    (5)读锁和写锁一定要分开使用,否则达不到效果。

    多线程的那点儿事-原子锁

    原子锁是多线程编程中的一个特色。然而,在平时的软件编写中,原子锁的使用并不是很多。这其中原因很多,我想主要有两个方面。第一,关于原子锁这方面的内容介绍的比较少;第二,人们在编程上面习惯于已有的方案,如果没有特别的需求,不过贸然修改已存在的代码。毕竟对很多人来说,不求有功,但求无过。保持当前代码的稳定性还是很重要的。

    其实,早在
    多线程数据互斥
    这篇博客中,我们就已经介绍过原子锁。本篇博客主要讨论的就是原子锁怎么使用。中间的一些用法只是我个人的一些经验,希望能够抛砖引玉,多听听大家的想法。

    (1)查找函数中原子锁

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

    unsigned int count = 0;
    
    int find_data_process()
    {
      if(/* data meets our standards */){
        EnterCriticalSection(&cs);
        count ++;
        LeaveCriticalSection(&cs);
      }
    }
    

    我们看到代码中间使用到了锁,那么势必会涉及到系统调用和函数调度。所以,在执行效率上会大打折扣。那么如果使用原子锁呢?

    unsigned int count = 0;
    
    int find_data_process()
    {
        if(/* data meets our standards */){
            InterLockedIncrement(&count);
        }
    }
    

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

    (2)代码段中的原子锁

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

    void data_process()
    {
        EnterCriticalSection(&cs);
        do_something();
        LeaveCriticalSection(&cs);
    }
    

    如果改成原子锁呢,会是什么样的呢?

    unsigned int lock = 0;
    
    void data_process()
    {
        while(1 == InterLockedCompareExchange(&lock, 1, 0));
        do_something();
        lock = 0;
    }
    

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

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

    多线程的那点儿事-C++锁

    编写程序不容易,编写多线程的程序更不容易。相信编写过多线程的程序都应该有这样的一个痛苦过程,什么样的情况呢?朋友们应该看一下代码就明白了,

    void data_process()
    {
      EnterCriticalSection();
    
      if(/* error happens */)
      {
          LeaveCriticalSection();
          return;
      }
    
      if(/* other error happens */)
      {
          return;
      }
    
      LeaveCriticalSection();
    }
    

    上面的代码说明了一种情形。这种多线程的互斥情况在代码编写过程中是经常遇到的。所以,每次对共享数据进行操作时,都需要对数据进行EnterCriticalSection 和 LeaveCriticalSection 的操作。但是,这中间也不是一帆风顺的。很有可能你会遇到各种各样的错误。那么,这时候你的程序就需要跳出去了。可能一开始遇到 error 的时候,你还记得需要退出临界区。但是,如果错误多了,你未必记得还有这个操作了。这一错就完了,别的线程就没有机会获取这个锁了。

    那么,有没有可能利用 C++ 的特性,自动处理这种情况呢?还真有。我们看看下面这个代码,

    class CLock
    {
        CRITICAL_SECTION& cs;
    
    public:
        CLock(CRITICAL_SECTION& lock):cs(lock){
            EnterCriticalSection(&cs);
        }
    
        ~CLock() {
            LeaveCriticalSection(&cs);
        }
    }
    
    class Process
    {
        CRITICAL_SECTION cs;
        /* other data */
    
    public:
        Process(){
            InitializeCriticalSection(&cs);
        }
    
        ~Process() {DeleteCriticalSection(&cs);}
    
        void data_process(){
            CLock lock(cs);
    
            if(/* error happens */){
                return;
            }
    
            return;
        }
    }
    

    C++ 的一个重要特点就是,不管函数什么时候退出,系统都会自动调用类的析构函数。在 Process 类的 data_process 函数中,,函数在开始就创建了一个CLock 类。那么,在创建这个类的时候,其实就开始了临界区的pk。那么一旦进入到临界区当中,在error中能不能及时退出临界区呢?此时,c++ 析构函数的优势出现了。因为不管错误什么时候出现,在函数退出之前,系统都会帮我们善后。什么善后呢?就是系统会调用 CLock 的析构函数,也就是退出临界区。这样,我们的目的就达到了。

    其实,这就是一个 c++ 的 trick。

    多线程的那点儿事-windows锁

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

    (1)临界区

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

      InitializeCriticalSection
      EnterCriticalSection
      LeaveCriticalSection
      DeleteCriticalSection
    

    如果想要对数据进行互斥操作的话,也很简单,这样做就可以了,

      EnterCriticalSection(/*...*/)
          do_something();
      LeaveCriticalSection(/*...*/)
    

    (2)互斥锁

    互斥锁也是一种锁。和临界区不同的是,它可以被不同进程使用,因为它有名字。同时,获取锁和释放锁的线程必须是同一个线程。常用的互斥锁操作有

      CreateMutex
      OpenMutex
      ReleaseMutex
    

    那么,怎么用互斥锁进行数据的访问呢,其实不难。

      WaitForSingleObject(/*...*/);
          do_something();
      ReleaseMutex(/*...*/);
    

    (3)信号量

    信号量是使用的最多的一种锁结果,也是最方便的一种锁。围绕着信号量,人们提出了很多数据互斥访问的方案,pv 操作就是其中的一种。如果说互斥锁只能对单个资源进行保护,那么信号量可以对多个资源进行保护。同时信号量在解锁的时候,可以被另外一个thread进行解锁操作。目前,常用的信号量操作有,

      CreateSemaphore
      OpenSemaphore
      ReleaseSemaphore
    

    信号量的使用和互斥锁差不多。关键是信号量在初始化的时候需要明确当前资源的数量和信号量的初始状态是什么,

      WaitForSingleObject(/*...*/);
          do_something();
      ReleaseSemaphore(/*...*/);
    

    (4)event对象

    event 对象是 windows 下面很有趣的一种锁结构。从某种意义上说,它和互斥锁很相近,但是又不一样。因为在 thread 获得锁的使用权之前,常常需要 main 线程调用 SetEvent 设置一把才可以。关键是,在 thread 结束之前,我们也不清楚当前 thread 获得 event 之后执行到哪了。所以使用起来,要特别小心。常用的 event 操作有,

      CreateEvent
      OpenEvent
      PulseEvent
      ResetEvent
      SetEvent
    

    我们对event的使用习惯于分成main thread和normal thread使用。main thread负责event的设置和操作,而normal thread负责event的等待操作。在CreateEvent的时候,要务必考虑清楚event的初始状态和基本属性。

    对于main thread,应该这么做,

      CreateEvent(/*...*/);
      SetEvent(/*...*/);
      WaitForMultiObjects(hThread, /*...*/);
      CloseHandle(/*...*/);
    

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

      while(1){
          WaitForSingleObject(/*...*/);
    
          /*...*/
      }
    

    总结

    (1)关于临界区、互斥区、信号量、event 在 msdn 上均有示例代码

    (2)一般来说,使用频率上信号量 > 互斥区 > 临界区 > 事件对象

    (3)信号量可以实现其他三种锁的功能,学习上应有所侧重

    (4)纸上得来终觉浅,多实践才能掌握它们之间的区别

    多线程的那点儿事-自旋锁

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

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

    static inline void __raw_spin_lock(raw_spinlock_t *lock)
    {
      asm volatile("
    1:	"
             LOCK_PREFIX " ; decb %0
    	"
             "jns 3f
    "
             "2:	"
             "rep;nop
    	"
             "cmpb $0,%0
    	"
             "jle 2b
    	"
             "jmp 1b
    "
             "3:
    	"
             : "+m" (lock->slock) : : "memory");
    }
    

    上面这段代码是怎么做到自旋锁的呢?我们可以一句一句看看,

    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下可以编过,朋友们可以自己试试

    多线程的那点事儿-数据互斥

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

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

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

    (1)关中断

    (2)数学互斥方法

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

    (4)cpu原子操作

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

    (1)关中断

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

    #include <stdio.h>
    
    int main()
    {
      __asm{
        cli
        sti
      }
      return 1;
    }
    

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

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

    (2)数学方法

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

    unsigned int flag[2] = {0};
    unsigned int turn = 0;
    
    void process(unsigned int index)
    {
      flag[index] = 1;
      turn =  1 - index;
    
      while(flag[1 - index] && (turn == (1 - index)));
      do_something();
      flag[index] = 0;
    }
    

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

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

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

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

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

    系统加锁过程,

    void Lock(HANDLE hLock)
    {
      __asm {cli};
    
      while(1){
        if(/* 锁可用*/){
          /* 设定标志,表明当前锁已被占用 */
          __asm {sti};
          return;
        }
    
        __asm{sti};
        schedule();
        __asm{cli};
      }
    }
    

    系统解锁过程,

    void UnLock(HANDLE hLock)
    {
      __asm {cli};
      /* 设定标志, 当前锁可用 */
      __asm{sti};
    }
    

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

    (4)CPU的原子操作

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

    InterLockedAdd
    InterLockedExchange
    InterLockedCompareExchange
    InterLockedIncrement
    InterLockedDecrement
    InterLockedAnd
    InterLockedOr
    

    多线程的那点儿事-数据同步

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

    #include <windows.h>
    #include <process.h>
    #include <stdio.h>
    
    unsigned int value = 0;
    
    void print(void* argv)
    {
      while(1){
        printf("&value = %x, value = %d
    ", &value, value);
        value ++;
        Sleep(1000);
      }
    }
    
    int main()
    {
      _beginthread( print, 0, NULL );
      _beginthread( print, 0, NULL);
    
      while(1)
        Sleep(0);
    
      return 1;
    }
    

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

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

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

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

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

      while(1){
        if( flag == true){
              flag = false;
          do_give_fish_food();
          flag = true;
        }
        Sleep(0);
      }
    

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

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

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

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

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

    多线程的那点儿事-死锁

    相信有过多线程编程经验的朋友,都吃过死锁的苦。除非你不使用多线程,否则死锁的可能性会一直存在。为什么会出现死锁呢?我想原因主要有下面几个方面:

    1. 个人使用锁的经验差异
    2. 模块使用锁的差异
    3. 版本之间的差异
    4. 分支之间的差异
    5. 修改代码和重构代码带来的差异

    不管什么原因,死锁的危机都是存在的。那么,通常出现的死锁都有哪些呢?我们可以一个一个看过来,

    (1)忘记释放锁

    void data_process()
    {
        EnterCriticalSection();
    
        if(/* error happens */)
            return;
    
        LeaveCriticalSection();
    }
    ``
    
    (2)**单线程重复申请锁**
    
    ```c++
    void sub_func()
    {
        EnterCriticalSection();
        do_something();
        LeaveCriticalSection();
    }
    
    void data_process()
    {
        EnterCriticalSection();
        sub_func();
        LeaveCriticalSection();
    }
    

    (3)双线程多锁申请

    void data_process1()
    {
        EnterCriticalSection(&cs1);
        EnterCriticalSection(&cs2);
        do_something1();
        LeaveCriticalSection(&cs2);
        LeaveCriticalSection(&cs1);
    }
    
    void data_process2()
    {
        EnterCriticalSection(&cs2);
        EnterCriticalSection(&cs1);
        do_something2();
        LeaveCriticalSection(&cs1);
        LeaveCriticalSection(&cs2);
    }
    

    (4)环形锁申请

    /*
    *             A   -  B
    *             |      |
    *             C   -  D
    */
    

    假设有A、B、C、D四个人在一起吃饭,每个人左右各有一只筷子。所以,这其中要是有一个人想吃饭,他必须首先拿起左边的筷子,再拿起右边的筷子。现在,我们让所有的人同时开始吃饭。那么就很有可能出现这种情况。每个人都拿起了左边的筷子,或者每个人都拿起了右边的筷子,为了吃饭,他们现在都在等另外一只筷子。此时每个人都想吃饭,同时每个人都不想放弃自己已经得到的一那只筷子。所以,事实上大家都吃不了饭。

    总结:

    (1)死锁的危险始终存在,但是我们应该尽量减少这种危害存在的范围

    (2)解决死锁花费的代价是异常高昂的

    (3)最好的死锁处理方法就是在编写程序的时候尽可能检测到死锁

    (4)多线程是一把双刃剑,有了效率的提高当然就有死锁的危险

    (5)某些程序的死锁是可以容忍的,大不了重启机器,但是有些程序不行

    多线程的那点儿事-基础篇

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

    (1)单CPU下的多线程

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

    1)按照优先级调度

    2)按照FIFO调度

    3)按照时间片调度等等

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

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

    struct task_struct {
    /* these are hardcoded - don't touch */
      long state;	/* -1 unrunnable, 0 runnable, >0 stopped */
      long counter;
      long priority;
      long signal;
      struct sigaction sigaction[32];
      long blocked;	/* bitmap of masked signals */
    /* various fields */
      int exit_code;
      unsigned long start_code,end_code,end_data,brk,start_stack;
      long pid,father,pgrp,session,leader;
      unsigned short uid,euid,suid;
      unsigned short gid,egid,sgid;
      long alarm;
      long utime,stime,cutime,cstime,start_time;
      unsigned short used_math;
    /* file system info */
      int tty;		/* -1 if no tty, so it must be signed */
      unsigned short umask;
      struct m_inode * pwd;
      struct m_inode * root;
      struct m_inode * executable;
      unsigned long close_on_exec;
      struct file * filp[NR_OPEN];
    /* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
      struct desc_struct ldt[3];
    /* tss for this task */
      struct tss_struct tss;
    };
    

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

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

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

    1)创建共享内存

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

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

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

    #include <unistd.h>
    #include <stdio.h>
    
    int value = 10;
    
    int main(int argc, char* argv[])
    {
        int pid = fork();
        if(!pid){
            Value = 12;
            return 0;
        }
        printf("value = %d
    ", value);
        return 1;
    }
    

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

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

    (2)多核CPU下的多线程

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

    #include <omp.h>
    #define MAX_VALUE 10000000
    
    double _test(int value)
    {
      int index;
      double result;
    
      result = 0.0;
      for(index = value + 1; index < MAX_VALUE; index +=2 )
        result += 1.0 / index;
    
      return result;
    }
    
    void test()
    {
      int index;
      int time1;
      int time2;
      double value1,value2;
      double result[2];
    
        time1 = 0;
      time2 = 0;
    
      value1 = 0.0;
      time1 = GetTickCount();
      for(index = 1; index < MAX_VALUE; index ++)
        value1 += 1.0 / index;
    
      time1 = GetTickCount() - time1;
    
      value2 = 0.0;
      memset(result , 0, sizeof(double) * 2);
      time2 = GetTickCount();
    
    #pragma omp parallel for
      for(index = 0; index < 2; index++)
        result[index] = _test(index);
    
      value2 = result[0] + result[1];
      time2 = GetTickCount() - time2;
    
      printf("time1 = %d,time2 = %d
    ",time1,time2);
      return;
    }
    

    (3)多线程编程

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

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

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

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

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

  • 相关阅读:
    supervise 用来监控服务,自动启动
    tee -a /var/log/jd.log
    类的构造函数与析构函数的调用顺序
    c++之带默认形参值的函数
    zoj1001-A + B Problem
    zoj1037-Gridland
    cf499A-Watching a movie
    cf478B-Random Teams 【排列组合】
    C++版修真小说
    Python_12-线程编程
  • 原文地址:https://www.cnblogs.com/Forgenvueory/p/12929495.html
Copyright © 2011-2022 走看看