线程同步和原子操作是windows开发中必不可少的一个话题,线程同步一般分为用户模式下的线程同步和内核模式下的线程同步。其中用户模式下的线程同步方案一般无法跨进程使用,所以性能也是最好的,如果没有跨进程线程同步的需求,推荐使用用户模型下的线程同步方案。

用户模式下的线程同步方案主要有以下几种:Interlocked系列函数同步、关键段同步、旋转锁同步、关键段同步

Interlocked系列函数

Interlocked系列函数是 Windows API 提供的一组原子操作函数,用于在多线程环境中安全地操作共享变量。当我们执行这些Interlocked系列函数的时候 ,函数会对总线发出一个硬件信号,这个硬件信号可以防止CPU同一时刻访问同一个内存地址,从而大达到原子访问的目的。

Interlocked系列函数的主要作用是同一时刻只会有一个线程访问一个变量

以下是一些常用的 Interlocked 系列函数:

1.    InterlockedIncrement:递增指定的变量。
2.    InterlockedDecrement:递减指定的变量。
3.    InterlockedExchange:将指定的变量设置为指定的值,并返回该变量的原值。
4.    InterlockedExchangeAdd:将指定的值加到指定的变量上,并返回该变量的原值。
5.    InterlockedCompareExchange:如果指定的变量的当前值等于指定的比较值,则将该变量设置为指定的值。
6.    InterlockedAnd:对指定的变量执行按位与操作。
7.    InterlockedOr:对指定的变量执行按位或操作。
8.    InterlockedXor:对指定的变量执行按位异或操作。

下面是这些Interlocked函数的原型,可以看到,Interlocked函数只是对LONG类型的变量进行同步和操作。所以,如果开发中,对一个整数变量有同步需求的话,可以使用该方案。我在开发中会经常使用该方案。

LONG InterlockedIncrement(
  _Inout_ LONG volatile *Addend
);

LONG InterlockedDecrement(
  _Inout_ LONG volatile *Addend
);

LONG InterlockedExchange(
  _Inout_ LONG volatile *Target,
  _In_    LONG          Value
);

LONG InterlockedExchangeAdd(
  _Inout_ LONG volatile *Addend,
  _In_    LONG          Value
);

LONG InterlockedCompareExchange(
  _Inout_ LONG volatile *Destination,
  _In_    LONG          Exchange,
  _In_    LONG          Comperand
);

LONG InterlockedAnd(
  _Inout_ LONG volatile *Destination,
  _In_    LONG          Value
);

LONG InterlockedOr(
  _Inout_ LONG volatile *Destination,
  _In_    LONG          Value
);

LONG InterlockedXor(
  _Inout_ LONG volatile *Destination,
  _In_    LONG          Value
);

接下来,我们用一个简单的案例来演示一些这些函数如何使用,当然我并没有在多线程中使用这些函数,而是在一个main函数中直接演示:

#include <iostream>
#include <Windows.h>
#include <process.h>

int main()
{
	long value = 0;
	InterlockedIncrement(&value);//变量值加1
	std::cout << "Value: " << value << std::endl;//输出1

	InterlockedDecrement(&value);//变量值减1
	std::cout << "Value: " << value << std::endl;//输出0

	InterlockedExchange(&value, 10);//将变量值设置为10
	std::cout << "Value: " << value << std::endl;//输出10

	InterlockedExchangeAdd(&value, 5);//将变量值加5
	std::cout << "Value: " << value << std::endl;//输出15

	InterlockedCompareExchange(&value, 20, 15);//如果变量值等于15,则将变量值设置为20
	std::cout << "Value: " << value << std::endl;//输出20
    return 0;
}

关键段

关键段中的段指的是一段代码,这种方案允许我们对一段代码上锁,同一时刻,只会有一个线程执行一段代码。我们把这种同步方案称为关键段,关键段运行在用户模式下,性能极高,而且非常符合我们的编码习惯(对一段代码加锁),所以这是我非常推荐使用的一种线程同步方案。C#中的lock锁本质上就是通过代码段来实现的。

关键段的特点
1.    高性能:关键段在用户模式下工作,不涉及内核模式的切换,因此性能较高。
2.    单进程:关键段只能用于同一进程内的线程同步,不能跨进程使用。
3.    独占访问:当一个线程进入关键段时,其他试图进入该关键段的线程将被阻塞,直到第一个线程离开关键段。

关键段的使用
使用关键段进行线程同步通常包括以下步骤:
1.    初始化关键段:在使用关键段之前,需要先初始化它。调用InitializeCriticalSection函数实现关键段初始化。
2.    进入关键段:线程在访问共享资源之前,必须先进入关键段。调用EnterCriticalSection函数进入关键段。
3.    离开关键段:线程在访问完共享资源后,必须离开关键段。调用LeaveCriticalSection离开关键段。
4.    删除关键段:在不再需要关键段时,删除它以释放资源。调用DeleteCriticalSection删除关键段。

下面代码,我们使用两个线程来计算从0到100的累加结果。但是这个累加过程,我们用到了全局变量g_num 用户保存累加结果,这个写法有点勉强,但是是为了演示关键段的线程同步效果。如果我们使用了关键段,两个线程执行的结果都是正确的结果4950.但是如果我们不适用关键段,就会发现每次执行的结果都不一样。因为对于全局变量g_num 的访问,没有做线程同步。

#include <windows.h>
#include <iostream>
#include <string>
#include <vector>
int g_num = 0;
CRITICAL_SECTION g_criticalSection;
DWORD WINAPI ThreadProc1(LPVOID lpParam)
{
    EnterCriticalSection(&g_criticalSection);
    g_num = 0;
    for (int i = 0; i < 100; i++)
    {
        g_num += i;
        Sleep(10);
    }
    std::cout << "ThreadProc1: " << g_num << std::endl;
    LeaveCriticalSection(&g_criticalSection);
    return 0;
}
DWORD WINAPI ThreadProc2(LPVOID lpParam)
{
    EnterCriticalSection(&g_criticalSection);
    g_num = 0;
    for (int i = 0; i < 100; i++)
    {
        g_num += i;
        Sleep(10);
    }
    std::cout << "ThreadProc2: " << g_num << std::endl;
    LeaveCriticalSection(&g_criticalSection);
    return 0;
}
int main()
{
    //初始化关键段
    InitializeCriticalSection(&g_criticalSection);
    HANDLE hThread1 = CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
    HANDLE hThread2 = CreateThread(NULL, 0, ThreadProc2, NULL, 0, NULL);

    // 等待线程结束
    WaitForSingleObject(hThread1, INFINITE);
    WaitForSingleObject(hThread2, INFINITE);

    // 关闭线程句柄
    CloseHandle(hThread1);
    CloseHandle(hThread2);

    // 删除关键段
    DeleteCriticalSection(&g_criticalSection);
    return 0;
}

运行结果如下:

旋转锁

对于关键段来说,如果一个线程尝试进入关键段的时候,发现资源无法访问。操作系统会将这个线程挂起,随后等待其他线程释放资源锁这个事件发生之后,才可以进入关键段执行代码。

对于自旋锁来说,如果尝试进入一个关键的时候,发现资源无法访问,它会通过不断循环(自旋)的方式获取锁的状态,直到其他线程释放资源锁。这么做的好处是不用线程的上下文切换,因为线程并没有被挂起。但缺点是会浪费大量的CPU资源。所以自旋锁,不适用于加锁时间比较长的场景。

下面代码我们使用C++的std::atomic_flag来实现一个自旋锁类Spinlock ,然后同样是计算0到100的累加和。每次进入代码段之前,都要通过Spinlock.lock()加锁,然后通过spinlock.unlock();释放锁,可以和明显看到,在自旋锁Spinlock 的内部,使用了一个while死循环来不断循环(自旋)的方式获取锁(std::atomic_flag)的状态。当然这里不讲解C++的语法,如果对std::atomic_flag不了解的话,可以使用AI工具自己学习。

#include <atomic>
#include <thread>
#include <iostream>

// 自旋锁
class Spinlock {
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT;

public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 自旋等待
        }
    }

    void unlock() {
        flag.clear(std::memory_order_release);
    }
};

Spinlock spinlock;
int g_num = 0;

void ThreadProc1() {
    spinlock.lock();
    g_num = 0;
    for (int i = 0; i < 100; i++) {
        g_num += i;
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
    std::cout << "ThreadProc1: " << g_num << std::endl;
    spinlock.unlock();
}

void ThreadProc2() {
    spinlock.lock();
    g_num = 0;
    for (int i = 0; i < 100; i++) {
        g_num += i;
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
    std::cout << "ThreadProc2: " << g_num << std::endl;
    spinlock.unlock();
}

int main() {
    std::thread t1(ThreadProc1);
    std::thread t2(ThreadProc2);

    t1.join();
    t2.join();

    return 0;
}

上述代码的执行效果如下:

读写锁

对于上面的线程来说,一个线程一旦拿到这个资源锁,其他线程将无法执行相关代码。但是,对于读写锁来说,允许多个线程同时读取资源,同时在一个线程写的时候,其他线程可以进行读取资源。

读写锁的主要目的是提高并发性,特别是在读操作远多于写操作的情况下。

以下是读写锁的实现逻辑:
读锁:
1、当一个线程请求读锁时,如果没有线程持有写锁,则允许该线程获取读锁。
2、允许多个线程同时持有读锁。
3、如果有线程持有写锁,读锁请求将被阻塞,直到写锁被释放。
写锁:
1、当一个线程请求写锁时,如果没有线程持有读锁或写锁,则允许该线程获取写锁。
2、只允许一个线程持有写锁。
3、如果有线程持有读锁或写锁,写锁请求将被阻塞,直到所有读锁和写锁被释放。

下面我们使用简单的代码来实现读写锁,同样是计算0-100的累加和,但是两个线程负责累加,一个线程负责读取。

#include <windows.h>
#include <iostream>
#include <string>
#include <vector>

int g_num = 0;
SRWLOCK g_srwLock;

DWORD WINAPI ReaderThreadProc(LPVOID lpParam)
{
    for (int i = 0; i < 10; i++)
    {
        // 获取读锁
        AcquireSRWLockShared(&g_srwLock);
        std::cout << "ReaderThreadProc read: " << g_num << std::endl;
        // 释放读锁
        ReleaseSRWLockShared(&g_srwLock);
        Sleep(10);
    }
    return 0;
}

DWORD WINAPI WriterThreadProc(LPVOID lpParam)
{
    for (int i = 0; i < 10; i++)
    {
        //获取写锁
        AcquireSRWLockExclusive(&g_srwLock);
        g_num++;
        std::cout << "WriterThreadProc write: " << g_num << std::endl;
        //释放写锁
        ReleaseSRWLockExclusive(&g_srwLock);
        Sleep(10);
    }
    return 0;
}

int main()
{
    // 初始化读写锁
    InitializeSRWLock(&g_srwLock);

    // 创建读线程
    HANDLE hReaderThread1 = CreateThread(NULL, 0, ReaderThreadProc, NULL, 0, NULL);
    HANDLE hReaderThread2 = CreateThread(NULL, 0, ReaderThreadProc, NULL, 0, NULL);

    // 创建写线程
    HANDLE hWriterThread = CreateThread(NULL, 0, WriterThreadProc, NULL, 0, NULL);

    // 等待线程结束
    WaitForSingleObject(hReaderThread1, INFINITE);
    WaitForSingleObject(hReaderThread2, INFINITE);
    WaitForSingleObject(hWriterThread, INFINITE);

    // 关闭线程句柄
    CloseHandle(hReaderThread1);
    CloseHandle(hReaderThread2);
    CloseHandle(hWriterThread);

    return 0;
}

条件变量

条件变量一般配合锁来使用,它 允许在某些场景下,需要等待其他线程触发一些事件才能执行。我们以生产着-消费者来说。生产者线程在写数据的时候,获取写锁,阻塞其他所有线程读取数据。这个逻辑是没问题的。但是如果生产者一直没有写数据。而消费者却要不断的获取读锁,并且获取成功,执行代码(无用代码)。这个时候,我们需要消费者等待生产者触发写数据这个操作的时候,通知消费者继续执行,请看下面的代码。

主要通过SleepConditionVariableCS函数阻塞线程执行,只有当生产者线程WriterThreadProc函数调用WakeAllConditionVariable函数之后,线程才能继续往下执行。

#include <windows.h>
#include <iostream>
#include <string>
#include <vector>

int g_num = 0;
CONDITION_VARIABLE cv; // 条件变量
CRITICAL_SECTION cs;   // 关键段
bool ready = false;    // 条件变量的条件

DWORD WINAPI ReaderThreadProc(LPVOID lpParam)
{
    for (int i = 0; i < 10; i++)
    {
        EnterCriticalSection(&cs);
        while (!ready)
        {
            // 等待条件变量
            SleepConditionVariableCS(&cv, &cs, INFINITE);
        }
        std::cout << "ReaderThreadProc read: " << g_num << std::endl;
        LeaveCriticalSection(&cs);
        Sleep(10);
    }
    return 0;
}

DWORD WINAPI WriterThreadProc(LPVOID lpParam)
{
    for (int i = 0; i < 10; i++)
    {
        EnterCriticalSection(&cs);
        g_num++;
        std::cout << "WriterThreadProc write: " << g_num << std::endl;
        ready = true;
        // 唤醒所有等待的线程
        WakeAllConditionVariable(&cv);
        LeaveCriticalSection(&cs);
        Sleep(10);
    }
    return 0;
}

int main()
{
    // 初始化条件变量和关键段
    InitializeConditionVariable(&cv);
    InitializeCriticalSection(&cs);

    // 创建读线程
    HANDLE hReaderThread1 = CreateThread(NULL, 0, ReaderThreadProc, NULL, 0, NULL);
    HANDLE hReaderThread2 = CreateThread(NULL, 0, ReaderThreadProc, NULL, 0, NULL);

    // 创建写线程
    HANDLE hWriterThread = CreateThread(NULL, 0, WriterThreadProc, NULL, 0, NULL);

    // 等待线程结束
    WaitForSingleObject(hReaderThread1, INFINITE);
    WaitForSingleObject(hReaderThread2, INFINITE);
    WaitForSingleObject(hWriterThread, INFINITE);

    // 关闭线程句柄
    CloseHandle(hReaderThread1);
    CloseHandle(hReaderThread2);
    CloseHandle(hWriterThread);

    // 删除关键段
    DeleteCriticalSection(&cs);

    return 0;
}