一文搞定c++多线程同步机制

技术博客 (541) 2023-09-16 16:20:01

c++多线程同步机制

前序文章:一文搞定c++多线程

同步与互斥

现代操作系统都是多任务操作系统,通常同一时刻有大量可执行实体,则运行着的大量任务可能需要访问或使用同一资源,或者说这些任务之间具有依赖性。

  • 线程同步:线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。例如:两个线程A和B在运行过程中协同步调,按预定的先后次序运行,比如 A 任务的运行依赖于 B 任务产生的数据。
  • 线程互斥:线程互斥是指对于共享的操作系统资源,在各线程访问时具有排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许有限的线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。例如:两个线程A和B在运行过程中共享同一变量,但为了保持变量的一致性,如果A占有了该资源则B需要等待A释放才行,如果B占有了该资源需要等待B释放才行。

为什么需要线程同步

​ 由于现在操作系统支持多个线程运行,可能多个线程之间会共享同一资源。当多个线程去访问同一资源时,如果不加以干预,可能会引起冲突。例如,多个线程同时访问同一个全局变量,如果都是读取操作,则不会出现问题。如果一个线程负责改变此变量的值,而其他线程负责同时读取变量内容,则不能保证读取到的数据是经过写线程修改后的。为了确保读线程读取到的是经过修改的变量,就必须在向变量写入数据时禁止其他线程对其的任何访问,直至赋值过程结束后再解除对其他线程的访问限制。这种保证线程能了解其他线程任务处理结束后的处理结果而采取的保护措施即为线程同步。

​ 举个例子,现在你银行卡里有100元,然后一个线程去执行消费,一个线程去执行充值,如果不加以干预,则可能出现这样的情况:消费的线程读取到你的卡里有100元,然后由于线程切换保存了当前的状态就去执行充值线程,充值线程完成充值后你的卡里实际上应该是10000元,然后切换到消费进程,消费进程由于已经读取过卡里的钱所以会直接进行之后的操作,完成后计算得到卡里的钱应该改为50,这便会将你真实的卡里的钱改成50,这当然是我们不希望看到的!如果进行了线程同步操作,当消费线程进行时,由于这是对数据进行写的操作,那么其他充值线程都需要被阻塞直至消费进程结束占据资源,这样便不会导致数据的不一致。

​ 举个代码例子,两个线程对一个共享数据进行++操作并且输出出来,代码如下:

#include <iostream>
#include <thread>
#include <Windows.h>
using namespace std;
int share = 0;  //共享变量
void thread1()
{ 
   
    while(share<20)
    { 
   
        share++;
        cout << "this is thread1! share is " << share << endl;
        Sleep(100);
    }
}
void thread2()
{ 
   
    while (share < 20)
    { 
   
        share++;
        cout << "this is thread2! share is " << share << endl;
        Sleep(100);
    }
}

int main()
{ 
   
    thread task1(thread1); 
    thread task2(thread2); 
    task1.join();
    task2.join();
    cout << "main thread!" << endl;
}   

某一次的运行结果:

PS D:\vscode_c> ./test
this is thread1! share is 2
this is thread2! share is 2
this is thread1! share is 3
this is thread2! share is 4
this is thread1! share is this is thread2! share is 6
6
this is thread2! share is 8
this is thread1! share is 8
this is thread2! share is 9
this is thread1! share is 10
this is thread1! share is 12
this is thread2! share is 13
this is thread1! share is 14
this is thread2! share is 15
this is thread1! share is 16
this is thread2! share is this is thread1! share is 18
18
this is thread1! share is 20
this is thread2! share is 20
main thread!

可以看到,不但出现两个线程读取的变量值一样的现象(我们当然期望的是每一行都是一个唯一的数字并且有一个换行),还出现了cout的内容包括数字和换行符位置也有些错乱。主要原因是share和cout的缓冲区是thread1和thread2共享的,由于两个线程同时运行,便可能将一个已经修改的值读取,或者将另一个线程已经读取但是未修改的值进行读取,还有可能将另一个线程已经放入缓冲区的内容输出。当然,这个输出是不符合我们预期的!

mutex互斥锁

互斥锁是一种简单的通过加锁的方式控制多个线程对共享资源的访问,互斥锁有两个状态,上锁与解锁(lock和unlock)。lock互斥锁是一个原子操作,这说明在同一时刻只能有一个线程锁住互斥锁,不会出现同时上锁的情况,同时,互斥锁具有唯一性,一旦上锁,其他线程不能够再将其锁住。当一个互斥锁被锁住时,其他希望锁住该锁的线程将被挂起,直至该互斥锁被unlock解开,则这些线程将被唤醒并其中一个将再次抢占成功。

互斥锁的通常执行流程如下:

  • 在访问共享资源的临界区前,将互斥锁锁住 lock
  • 在完成访问共享资源的操作后,将互斥锁unlock
  • 这期间,其他线程如果需要访问共享资源将调用lock,将自身挂起,直至该互斥锁被unlock才行

利用互斥锁修改上面的代码:

#include <iostream>
#include <thread>
#include <Windows.h>
#include<mutex>
using namespace std;
mutex mut;
int share = 0;
void thread1()
{ 
   
    while(share<20)
    { 
   
        mut.lock();   //将互斥锁进行lock
        share++;
        cout << "this is thread1! share is " << share << endl;
        Sleep(100);
        mut.unlock();  //unlock 解开互斥锁
    }
}
void thread2()
{ 
   
    while (share < 20)
    { 
   
        mut.lock();   //将互斥锁进行lock
        share++;
        cout << "this is thread2! share is " << share << endl;
        Sleep(100);
        mut.unlock();  //unlock 解开互斥锁
    }
}

int main()
{ 
   
    thread task1(thread1); 
    thread task2(thread2); 
    task1.join();
    task2.join();
    cout << "main thread!" << endl;
}   

查看运行结果:

PS D:\vscode_c> ./test                
this is thread1! share is 1
this is thread2! share is 2
this is thread1! share is 3
this is thread2! share is 4
this is thread2! share is 5
this is thread2! share is 6
this is thread2! share is 7
this is thread2! share is 8
this is thread2! share is 9
this is thread1! share is 10
this is thread2! share is 11
this is thread1! share is 12
this is thread1! share is 13
this is thread1! share is 14
this is thread2! share is 15
this is thread2! share is 16
this is thread2! share is 17
this is thread1! share is 18
this is thread1! share is 19
this is thread2! share is 20
main thread!

很显然,这是符合我们预期的。不过事情总要做到更好,这个方法有什么问题呢?试想一下,如果在lock和unlock之间发生了异常,则可能永远不会执行到unlock,另一个进程将永远被挂起在那里等待。

为了解决该问题,根据对象的析构函数自动调用的原理,c++11推出了std::lock_guard自动释放锁,其原理是:声明一个局部的lock_guard对象,在其构造函数中进行加锁,在其析构函数中进行解锁。最终的结果就是:在定义该局部对象的时候加锁(调用构造函数),出了该对象作用域的时候解锁(调用析构函数)。

在C++中,通过构造std::mutex的实例来创建互斥元,可通过调用其成员函数lock()和unlock()来实现加锁和解锁,然后这是不推荐的做法,因为这要求程序员在离开函数的每条代码路径上都调用unlock(),包括由于异常所导致的在内。作为替代,标准库提供了std::lock_guard类模板,实现了互斥元的RAII惯用语法(资源获取即初始化)。该对象在构造时锁定所给的互斥元,析构时解锁该互斥元,从而保证被锁定的互斥元始终被正确解锁

#include <iostream>
#include <thread>
#include <Windows.h>
#include<mutex>
using namespace std;
mutex mut;
int share = 0;
void thread1()
{ 
   
    while(share<20)
    { 
   
        std::lock_guard<std::mutex> mtx_locker(mut);  //用lock_guard实现互斥锁
        if(share>=20)
            break;
        share++;
        cout << "this is thread1! share is " << share << endl;
        Sleep(100);
    }
}
void thread2()
{ 
   
    while (share < 20)
    { 
   
            std::lock_guard<std::mutex> mtx_locker(mut);  //用lock_guard实现互斥锁
            if (share >= 20)
                break;
            share++;
            cout << "this is thread2! share is " << share << endl;
            Sleep(100);
    }
}

int main()
{ 
   
    thread task1(thread1); 
    thread task2(thread2); 
    task1.join();
    task2.join();
    cout << "main thread!" << endl;
}   

运行结果:

PS D:\vscode_c> ./test                
this is thread1! share is 1
this is thread2! share is 2
this is thread2! share is 3
this is thread2! share is 4
this is thread2! share is 5
this is thread2! share is 6
this is thread2! share is 7
this is thread2! share is 8
this is thread2! share is 9
this is thread2! share is 10
this is thread2! share is 11
this is thread1! share is 12
this is thread2! share is 13
this is thread2! share is 14
this is thread2! share is 15
this is thread2! share is 16
this is thread1! share is 17
this is thread2! share is 18
this is thread2! share is 19
this is thread1! share is 20
main thread!

win32的四种同步方式

临界区

临界区 (Critical Section) 是一段独占对某些共享资源访问的代码,在任意时刻只允许一个线程对共享资源进行访问。如果有多个线程试图同时访问临界区,那么在有一个线程进入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操作共享资源的目的。

临界区在使用时以CRITICAL_SECTION结构对象保护共享资源,并分别用 EnterCriticalSection()和LeaveCriticalSection() 函数去标识和释放一个临界区。所用到的CRITICAL_SECTION结构对象必须经过InitializeCriticalSection() 的初始化后才能使用,而且必须确保所有线程中的任何试图访问此共享资源的代码都处在此临界区的保护之下。否则临界区将不会起到应有的作用,共享资源依然有被破坏的可能。

临界区的使用:

  • 定义一个CRITICAL_SECTION类型的变量

  • 调用InitializeCriticalSection函数对变量进行初始化,函数的作用是初始化临界区,唯一的参数是指向结构体CRITICAL_SECTION的指针变量

    VOID InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection )
    
  • 为了将某段代码设置为临界区,在进入这段代码前调用EnterCriticalSection函数。该函数的作用是使调用该函数的线程进入已经初始化的临界区,并拥有该临界区的所有权。这是一个阻塞函数,如果线程获得临界区的所有权成功,则该函数将返回,调用线程继续执行,否则该函数将一直等待,这样会造成该函数的调用线程也一直等待。如果不想让调用线程等待(非阻塞),则应该使用TryEnterCriticalSection函数

    VOID WINAPI EnterCriticalSection(__inout LPCRITICAL_SECTION lpCriticalSection);
    
  • 在临界区代码后,需要调用LeaveCriticalSection函数。该函数的作用是使调用该函数的线程离开临界区并释放对该临界区的所有权,以便让其他线程也获得访问该共享资源的机会

    void WINAPI LeaveCriticalSection( _Inout_LPCRITICAL_SECTION lpCriticalSection);
    

    如果一个线程在进入临界区后没有调用LeaveCriticalSection,则会出现等待进入临界区的线程无限期等待的问题

  • 最后释放掉CRITICAL_SECTION结构指针,该函数的作用是删除程序中已经被初始化的临界区。如果函数调用成功,则程序会将内存中的临界区删除,防止出现内存错误。

    void WINAPI DeleteCriticalSection(_Inout_ LPCRITICAL_SECTION lpCriticalSection);
    

tips:单进程的线程可以使用临界资源对象来解决同步互斥问题,该对象不能保证哪个线程能够获得到临界资源对象,因而该系统能公平的对待每一个线程

利用临界区解决上面的问题:

#include <iostream>
#include <thread>
#include <windows.h>
#include<mutex>
using namespace std;
CRITICAL_SECTION Critical; //定义临界区句柄
int share = 0;
void thread1()
{ 
   
    while(share<20)
    { 
   
        EnterCriticalSection(&Critical);
        if(share>=20)
            break;
        share++;
        cout << "this is thread1! share is " << share << endl;
        Sleep(100);
        LeaveCriticalSection(&Critical);
    }
}
void thread2()
{ 
   
    while (share < 20)
    { 
   
        EnterCriticalSection(&Critical);
        if (share >= 20)
            break;
        share++;
        cout << "this is thread2! share is " << share << endl;
        Sleep(100);
        LeaveCriticalSection(&Critical);
    }
}

int main()
{ 
   
    InitializeCriticalSection(&Critical); //初始化临界区对象
    thread task1(thread1); 
    thread task2(thread2); 
    task1.join();
    task2.join();
    cout << "main thread!" << endl;
}   

写起来和mutex类似,主要注意 的是一定要先初始化临界区对象。

运行结果:

PS D:\vscode_c> ./test                
this is thread1! share is 1
this is thread1! share is 2
this is thread1! share is 3
this is thread1! share is 4
this is thread1! share is 5
this is thread1! share is 6
this is thread2! share is 7
this is thread2! share is 8
this is thread2! share is 9
this is thread1! share is 10
this is thread1! share is 11
this is thread1! share is 12
this is thread1! share is 13
this is thread1! share is 14
this is thread1! share is 15
this is thread1! share is 16
this is thread1! share is 17
this is thread1! share is 18
this is thread1! share is 19
this is thread1! share is 20
main thread!
事件

事件(Event)是WIN32提供的最灵活的线程间同步方式,事件可以处于激发状态或未激发状态。应用时,通过使用 CreateEvent 函数创建事件,然后使用信号控制线程运行。其中将事件变为有信号可使用 SetEvent 函数,将事件信号复位(变为无信号)可使用 ResetEvent 函数,信号可以配合 WaitForSingleObject 函数对线程的同步进行控制,当有信号时,此函数便会放行;无信号时,此函数会将阻塞。

根据状态变迁方式的不同,事件可分为两类:
(1)手动设置:这种对象只可能用程序手动设置,在需要该事件或者事件发生时,采用SetEvent及ResetEvent来进行设置。
(2)自动恢复:一旦事件发生并被处理后,自动恢复到没有事件状态,不需要再次设置。

相关的函数:

函数名 函数说明
CreateEvent Creates or opens a named or unnamed event object.
CreateEventEx Creates or opens a named or unnamed event object and returns a handle to the object.
OpenEvent Opens an existing named event object.
PulseEvent Sets the specified event object to the signaled state and then resets it to the nonsignaled state after releasing the appropriate number of waiting threads.
ResetEvent Sets the specified event object to the nonsignaled state.
SetEvent Sets the specified event object to the signaled state.

CreateEvent用于创建事件对象,函数原型为:

HANDLE WINAPI CreateEvent(
  _In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
  _In_     BOOL                  bManualReset,
  _In_     BOOL                  bInitialState,  
  _In_opt_ LPCTSTR               lpName
);

着重强调一下第二个参数,CreateEvent的第二个参数 bManualReset 表示指定将事件对象创建成手动复原还是自动复原,如果是TRUE,那么必须用ResetEvent函数来手工将事件的状态复原到无信号状态。如果设置为FALSE,当事件被一个等待线程释放以后,系统将会自动将事件状态复原为无信号状态。第三个参数bInitialState 表示事件对象的初始状态。如果为true,则表示该事件对象初始时为有信号状态

利用事件解决上面的问题:

#include <iostream>
#include <thread>
#include <windows.h>
#include<mutex>
using namespace std;
HANDLE hEvent; //定义事件句柄
int share = 0;
void thread1()
{ 
   
    while(share<20)
    { 
   
        WaitForSingleObject(hEvent, INFINITE); //等待对象为有信号状态
        if(share>=20)
            break;
        share++;
        cout << "this is thread1! share is " << share << endl;
        Sleep(100);
        SetEvent(hEvent);  //将事件设置为有信号状态
    }
}
void thread2()
{ 
   
    while (share < 20)
    { 
   
        WaitForSingleObject(hEvent, INFINITE); //等待对象为有信号状态
        if (share >= 20)
            break;
        share++;
        cout << "this is thread2! share is " << share << endl;
        Sleep(100);
        SetEvent(hEvent);  //将事件设置为有信号状态
    }
}

int main()
{ 
   
    hEvent = CreateEvent(NULL, FALSE, TRUE, "event");  //创建事件 是自动恢复状态
    thread task1(thread1); 
    thread task2(thread2); 
    task1.join();
    task2.join();
    cout << "main thread!" << endl;
}   

运行结果:

PS D:\vscode_c> ./test                
this is thread1! share is 1
this is thread2! share is 2
this is thread1! share is 3
this is thread2! share is 4
this is thread1! share is 5
this is thread2! share is 6
this is thread1! share is 7
this is thread2! share is 8
this is thread1! share is 9
this is thread2! share is 10
this is thread1! share is 11
this is thread2! share is 12
this is thread1! share is 13
this is thread2! share is 14
this is thread1! share is 15
this is thread2! share is 16
this is thread1! share is 17
this is thread2! share is 18
this is thread1! share is 19
this is thread2! share is 20
main thread!
信号量

信号量是维护0到指定最大值之间的同步对象,用于线程的同步或者限制线程运行的数量。信号量状态在其计数大于0时是有信号的,而其计数是0时是无信号的。信号量对象在控制上可以支持有限数量共享资源的访问。

通常来说,信号量具有如下特点:

  • 如果当前资源的数量大于0,则信号量有效
  • 如果当前资源数量是0,则信号量无效
  • 当前资源的数量不能够为负值
  • 当前资源数量一定小于等于最大资源数量

信号量相关的函数:

//头文件
#include <windows.h>

//创建信号量API
HANDLE WINAPI CreateSemaphore(
 _In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,//指向SECURITY_ATTRIBUTES的指针;
 _In_     LONG                  lInitialCount,          //信号量对象的初始值;
 _In_     LONG                  lMaximumCount,  //信号量对象的最大值,这个值必须大于0;
 _In_opt_ LPCTSTR               lpName                 //信号量对象的名称;
);

//等待信号量API
DWORD WINAPI WaitForSingleObject(
  _In_ HANDLE hHandle,          //信号量对象句柄
  _In_ DWORD  dwMilliseconds    //等待信号量时间,INFINET代表永久等待;
);

//打开信号量
HANDLE OpenSemaphore (
   DWORD fdwAccess,      //access
   BOOL bInherithandle,  //如果允许子进程继承句柄,则设为TRUE
   PCTSTR pszName  //指定要打开的对象的名字
  );

//释放信号量句柄
BOOL WINAPI ReleaseSemaphore(
  _In_      HANDLE hSemaphore,         //信号量对象句柄;
  _In_      LONG   lReleaseCount,      //信号量释放的值,必须大于0;
  _Out_opt_ LPLONG lpPreviousCount     //前一次信号量值的指针,不需要可置为空;
);

用信号量解决上面的问题:

#include <iostream>
#include <thread>
#include <windows.h>
#include<mutex>
using namespace std;
HANDLE hSemaphore; //定义信号量句柄
int share = 0;
void thread1()
{ 
   
    while(share<20)
    { 
   
        WaitForSingleObject(hSemaphore, INFINITE); //等待信号量为有信号状态
        if(share>=20)
            break;
        share++;
        cout << "this is thread1! share is " << share << endl;
        Sleep(100);
        ReleaseSemaphore(hSemaphore, 1, nullptr);  //释放信号量
    }
}
void thread2()
{ 
   
    while (share < 20)
    { 
   
        WaitForSingleObject(hSemaphore, INFINITE); //等待信号量为有信号状态
        if (share >= 20)
            break;
        share++;
        cout << "this is thread2! share is " << share << endl;
        Sleep(100);
        ReleaseSemaphore(hSemaphore, 1, nullptr); //释放信号量
    }
}

int main()
{ 
   
    hSemaphore = CreateSemaphore(NULL, 1, 20, "semaphore"); //创建信号量
    thread task1(thread1); 
    thread task2(thread2); 
    task1.join();
    task2.join();
    cout << "main thread!" << endl;
}   

运行结果:

PS D:\vscode_c> ./test                
this is thread1! share is 1
this is thread2! share is 2
this is thread1! share is 3
this is thread2! share is 4
this is thread1! share is 5
this is thread2! share is 6
this is thread1! share is 7
this is thread2! share is 8
this is thread1! share is 9
this is thread2! share is 10
this is thread1! share is 11
this is thread2! share is 12
this is thread1! share is 13
this is thread2! share is 14
this is thread1! share is 15
this is thread2! share is 16
this is thread1! share is 17
this is thread2! share is 18
this is thread1! share is 19
this is thread2! share is 20
main thread!
互斥量

windows下提供有互斥对象机制。 只有拥有互斥对象的线程才有访问公共资源的权限,因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享。不过该互斥量和mutex基本一样,所以用移植性更好的mutex更好

互斥量主要的函数:

//创建互斥量
HANDLE WINAPI CreateMutex(
  __in          LPSECURITY_ATTRIBUTES lpMutexAttributes,//互斥对象的安全属性
  __in          BOOL bInitialOwner,//互斥对象的初始状态;TRUE表示互斥对象的线程ID为当前调度线程的线程ID,当前创建互斥对象的线程具有他的拥有权,互斥对象的递归计数器为1
  __in          LPCTSTR lpName//互斥对象的名称,NULL表示创建一个匿名的互斥对象
);
//释放互斥量
BOOL WINAPI ReleaseMutex(
  __in          HANDLE hMutex
);
//等待互斥量
DWORD WINAPI WaitForSingleObject(
  __in          HANDLE hHandle,//等待内核对象句柄
  __in          DWORD dwMilliseconds//等待时间,INFINITE表示无限等待
);

用互斥量解决上面的问题:

#include <iostream>
#include <thread>
#include <windows.h>
#include<mutex>
using namespace std;
HANDLE hMutex; //定义互斥对象句柄
int share = 0;
void thread1()
{ 
   
    while(share<20)
    { 
   
        WaitForSingleObject(hMutex, INFINITE);  //等待互斥量
        if(share>=20)
            break;
        share++;
        cout << "this is thread1! share is " << share << endl;
        Sleep(100);
        ReleaseMutex(hMutex);  //释放互斥量
    }
}
void thread2()
{ 
   
    while (share < 20)
    { 
   
        WaitForSingleObject(hMutex, INFINITE); //等待互斥量
        if (share >= 20)
            break;
        share++;
        cout << "this is thread2! share is " << share << endl;
        Sleep(100);
        ReleaseMutex(hMutex); //释放互斥量
    }
}

int main()
{ 
   
    hMutex = CreateMutex(NULL, false, "mutex"); //创建互斥对象
    thread task1(thread1); 
    thread task2(thread2); 
    task1.join();
    task2.join();
    cout << "main thread!" << endl;
}   

运行结果:

PS D:\vscode_c> ./test                
this is thread1! share is 1
this is thread2! share is 2
this is thread1! share is 3
this is thread2! share is 4
this is thread1! share is 5
this is thread2! share is 6
this is thread1! share is 7
this is thread2! share is 8
this is thread1! share is 9
this is thread2! share is 10
this is thread1! share is 11
this is thread2! share is 12
this is thread1! share is 13
this is thread2! share is 14
this is thread1! share is 15
this is thread2! share is 16
this is thread1! share is 17
this is thread2! share is 18
this is thread1! share is 19
this is thread2! share is 20
main thread!
THE END

发表回复