技术演进中的开发沉思-7:window编程系列-原子访问

昨天做了线程,其实在 Windows 开发的航程中,线程同步的探索,是其中一段充满挑战与惊喜的冒险。今天,就和大家一起走进 Windows 原子访问的隐秘世界,聊聊那些保障程序稳定运行的关键技术。

技术演进中的开发沉思-7:window编程系列-原子访问_第1张图片

一、原子访问:关于interlocked 系列函数

还记得刚接触多线程开发时,就像走进了一个嘈杂混乱的工厂。多个线程如同忙碌的工人,在同一时间对共享资源进行操作,常常因为争抢资源而引发混乱。有一次开发一个数据统计程序,多个线程同时对计数器进行操作,结果统计的数据总是出错,就像几个工人同时用一把锤子敲钉子,最后钉子歪歪扭扭,墙面也变得坑坑洼洼。

后来,我遇到了原子访问,借助 interlocked 系列函数,仿佛为共享资源加上了一面坚不可摧的 “原子盾牌”。interlocked 系列函数的操作具有原子性,就像给操作加上了一个 “魔法结界”,要么完整执行,要么完全不执行,不会被其他线程打断。比如我们对一个计数器进行操作,在多线程环境下,如果不使用原子操作,可能一个线程还没完成对计数器的读取和修改,另一个线程就介入了,导致结果错误。而使用InterlockedIncrement函数来增加计数器的值,就能保证操作的准确性。以下是一个简单的 VC++ 代码示例:


#include 

#include 

#include 

long g_nCounter = 0;

unsigned int __stdcall ThreadProc( void* pArguments )

{

for (int i = 0; i < 10000; ++i)

{

InterlockedIncrement(&g_nCounter);

}

_endthreadex(0);

return 0;

}

int main()

{

HANDLE hThread1 = (HANDLE)_beginthreadex(NULL, 0, ThreadProc, NULL, 0, NULL);

HANDLE hThread2 = (HANDLE)_beginthreadex(NULL, 0, ThreadProc, NULL, 0, NULL);

WaitForSingleObject(hThread1, INFINITE);

WaitForSingleObject(hThread2, INFINITE);

CloseHandle(hThread1);

CloseHandle(hThread2);

printf("Final counter value: %ld\n", g_nCounter);

return 0;

}

在这个代码中,两个线程同时对g_nCounter进行增加操作,由于使用了InterlockedIncrement函数,最终得到的计数器值是准确的,避免了数据竞争的问题。就好像给每个工人都分配了专属的锤子,大家各自有序工作,工厂也恢复了井然有序的生产节奏。

二、高速缓存行

随着对 Windows 开发的深入,我又接触到了高速缓存行。那时,我把它想象成内存世界里的 “快递驿站”。CPU 处理数据时,不会直接从内存中读取,而是先把数据加载到高速缓存中,就像快递员先把包裹送到驿站,我们再从驿站取包裹一样。高速缓存行就是这个 “驿站” 里存放包裹的固定大小的格子。

在多线程环境下,如果多个线程频繁访问同一高速缓存行中的不同变量,即使这些变量之间没有逻辑关联,也可能会因为高速缓存的更新机制导致性能问题,这就是所谓的 “伪共享”。记得有一次优化一个多线程数据处理程序,发现程序性能总是不理想。后来排查发现,有两个线程,一个线程频繁修改struct A中的变量a,另一个线程频繁读取struct A中的变量b,而a和b恰好位于同一个高速缓存行,每次一个线程修改了a,另一个线程读取b时,都因为高速缓存行的更新产生了额外的开销,就像两个快递员总是因为同一个驿站的包裹问题互相干扰,耽误了送货速度。

为了避免这种情况,我们可以通过合理的数据对齐,将不同线程访问的变量放置在不同的高速缓存行中。以下是一个简单的示例代码,展示如何通过数据对齐来减少伪共享:


#include 

#include 

#include 

// 定义一个结构体,并进行128字节对齐,确保不同线程访问的变量在不同高速缓存行

#pragma pack(push, 16)

struct CACHE_ALIGNED_STRUCT

{

long data1;

char padding[128 - sizeof(long)];

long data2;

};

#pragma pack(pop)

CACHE_ALIGNED_STRUCT g_CacheAligned;

unsigned int __stdcall ThreadProc1( void* pArguments )

{

for (int i = 0; i < 1000000; ++i)

{

InterlockedIncrement(&g_CacheAligned.data1);

}

_endthreadex(0);

return 0;

}

unsigned int __stdcall ThreadProc2( void* pArguments )

{

for (int i = 0; i < 1000000; ++i)

{

InterlockedIncrement(&g_CacheAligned.data2);

}

_endthreadex(0);

return 0;

}

int main()

{

HANDLE hThread1 = (HANDLE)_beginthreadex(NULL, 0, ThreadProc1, NULL, 0, NULL);

HANDLE hThread2 = (HANDLE)_beginthreadex(NULL, 0, ThreadProc2, NULL, 0, NULL);

WaitForSingleObject(hThread1, INFINITE);

WaitForSingleObject(hThread2, INFINITE);

CloseHandle(hThread1);

CloseHandle(hThread2);

printf("Data1: %ld, Data2: %ld\n", g_CacheAligned.data1, g_CacheAligned.data2);

return 0;

}

三、高级线程同步

在探索高级线程同步的过程中,我踩过不少坑,也总结出一些需要避免使用的方法,这些方法就像隐藏在暗处的 “危险陷阱”,看似可行,实则可能带来严重的问题。有一次在开发一个大型项目时,团队过度依赖全局变量来进行线程间的通信和同步,又没有进行适当的保护措施。结果项目运行时,就像多个施工队在同一栋大楼里施工,没有合理的规划和协调,都随意使用公共资源(全局变量),导致程序频繁出现错误,最后不得不花费大量时间进行重构。

还有一些不恰当的锁使用方式,例如在持有锁的情况下进行长时间的复杂操作,导致其他线程长时间等待,造成程序性能急剧下降。这让我想起曾经调试的一个程序,因为一个线程长时间占用锁,就像一个人一直霸占着电梯,后面等待的人只能干着急,整个大楼(程序)的通行效率(运行速度)都受到了影响。所以在进行高级线程同步时,我们要谨慎选择方法,避免陷入这些潜在的陷阱。

四、关键段:线程同步

关键段是我在 Windows 开发中常用的线程同步工具,它就像是线程同步中的 “专属房间”,同一时间只有一个线程能够进入这个房间,对房间内的资源进行操作。进入关键段的线程会持有一个锁,其他线程如果想要进入,就必须等待。

1、关键段:细节

在使用关键段时,有许多细节需要我们注意。首先是关键段的初始化和销毁。我们需要使用InitializeCriticalSection函数来初始化一个关键段对象,使用完后,要用DeleteCriticalSection函数进行销毁。例如在开发一个多线程文件读写程序时,为了保证同一时间只有一个线程能访问文件资源,我使用了关键段,以下是相关代码:

#include 

#include 

#include 

CRITICAL_SECTION g_CriticalSection;

long g_nCounter = 0;

unsigned int __stdcall ThreadProc( void* pArguments )

{

EnterCriticalSection(&g_CriticalSection);

for (int i = 0; i < 10000; ++i)

{

g_nCounter++;

}

LeaveCriticalSection(&g_CriticalSection);

_endthreadex(0);

return 0;

}

int main()

{

InitializeCriticalSection(&g_CriticalSection);

HANDLE hThread1 = (HANDLE)_beginthreadex(NULL, 0, ThreadProc, NULL, 0, NULL);

HANDLE hThread2 = (HANDLE)_beginthreadex(NULL, 0, ThreadProc, NULL, 0, NULL);

WaitForSingleObject(hThread1, INFINITE);

WaitForSingleObject(hThread2, INFINITE);

CloseHandle(hThread1);

CloseHandle(hThread2);

DeleteCriticalSection(&g_CriticalSection);

printf("Final counter value: %ld\n", g_nCounter);

return 0;

}

在这个代码中,通过EnterCriticalSection函数进入关键段,LeaveCriticalSection函数离开关键段,保证了在同一时间只有一个线程能够访问g_nCounter,避免了数据竞争,就像给文件资源的访问加上了一把专属钥匙,确保有序操作。

2、关键段和旋转锁

旋转锁可以看作是关键段的一种特殊形式。当一个线程尝试获取旋转锁时,如果锁已经被占用,它不会立即进入睡眠状态等待,而是在原地 “旋转”,不断尝试获取锁,直到获取成功。在一些线程竞争不激烈,且线程上下文切换开销较大的场景下,旋转锁可能会比普通关键段更高效。但如果线程竞争激烈,旋转锁会浪费大量的 CPU 资源,此时普通关键段可能更合适。这就好比在车流量小的路口,车辆可以缓慢绕圈等待通行;但在车水马龙的路口,这种绕圈等待只会造成拥堵,老老实实排队等待反而更高效。

3、关键段和错误处理

在使用关键段时,错误处理也不容忽视。如果在进入关键段或离开关键段的过程中出现错误,可能会导致关键段处于不一致的状态,进而引发程序崩溃或数据错误。有一次在项目中,因为疏忽没有处理进入关键段失败的情况,结果程序运行到一半突然崩溃,排查问题时花费了大量时间。从那以后,我每次编写代码时,都会对可能出现的错误进行妥善处理,例如在进入关键段失败时,进行适当的日志记录和错误提示,就像给程序安装了 “监控摄像头”,一旦有异常就能及时发现。

五、slim 读 / 写锁

slim 读 / 写锁是另一个实用的线程同步工具,它就像一个灵活的 “交通信号灯”,允许多个线程同时读取共享资源,但在写操作时,会独占资源。也就是说,当有线程进行写操作时,其他线程无论是读还是写,都必须等待。

在实际应用中,如果读操作远远多于写操作,使用 slim 读 / 写锁可以大大提高程序的并发性能。比如在开发一个在线文档查看系统时,大量用户同时读取文档内容(读操作),但只有少数管理员会进行文档修改(写操作)。这时使用 slim 读 / 写锁,多个读线程可以同时获取读锁,并发读取数据,而写线程在进行写操作时,会先获取写锁,阻止其他线程的读和写操作,保证数据的一致性。以下是一个简单的 VC++ 代码示例:


#include 

#include 

#include 

SRWLOCK g_SrwLock;

long g_nData = 0;

unsigned int __stdcall ReadThreadProc( void* pArguments )

{

AcquireSRWLockShared(&g_SrwLock);

printf("Read data: %ld\n", g_nData);

ReleaseSRWLockShared(&g_SrwLock);

_endthreadex(0);

return 0;

}

unsigned int __stdcall WriteThreadProc( void* pArguments )

{

AcquireSRWLockExclusive(&g_SrwLock);

g_nData++;

ReleaseSRWLockExclusive(&g_SrwLock);

_endthreadex(0);

return 0;

}

int main()

{

InitializeSRWLock(&g_SrwLock);

HANDLE hReadThread1 = (HANDLE)_beginthreadex(NULL, 0, ReadThreadProc, NULL, 0, NULL);

HANDLE hReadThread2 = (HANDLE)_beginthreadex(NULL, 0, ReadThreadProc, NULL, 0, NULL);

HANDLE hWriteThread = (HANDLE)_beginthreadex(NULL, 0, WriteThreadProc, NULL, 0, NULL);

WaitForSingleObject(hReadThread1, INFINITE);

WaitForSingleObject(hReadThread2, INFINITE);

WaitForSingleObject(hWriteThread, INFINITE);

CloseHandle(hReadThread1);

CloseHandle(hReadThread2);

CloseHandle(hWriteThread);

return 0;

}

六、条件变量

条件变量就像是线程间的 “通信使者”,它可以让线程在满足特定条件时才继续执行。在实际编程中,我们常常会遇到这样的场景:一个线程需要等待某个条件满足后才能继续执行,比如一个生产者 - 消费者模型中,消费者线程需要等待生产者线程生产出数据后才能进行消费。

1、queue 示例程序

下面通过一个简单的 queue 示例程序来看看条件变量的应用。在这个程序中,生产者线程向队列中添加数据,消费者线程从队列中取出数据。当队列为空时,消费者线程会等待,直到生产者线程添加数据并通知它。


#include 

#include 

#include 

#include 

CRITICAL_SECTION g_CriticalSection;

CONDITION_VARIABLE g_ConditionVariable;

std::queue g_Queue;

// 生产者线程

unsigned int __stdcall ProducerThreadProc( void* pArguments )

{

for (int i = 0; i < 10; ++i)

{

EnterCriticalSection(&g_CriticalSection);

g_Queue.push(i);

LeaveCriticalSection(&g_CriticalSection);

WakeConditionVariable(&g_ConditionVariable);

Sleep(100);

}

_endthreadex(0);

return 0;

}

// 消费者线程

unsigned int __stdcall ConsumerThreadProc( void* pArguments )

{

while (true)

{

EnterCriticalSection(&g_CriticalSection);

while (g_Queue.empty())

{

SleepConditionVariableCS(&g_ConditionVariable, &g_CriticalSection, INFINITE);

}

int data = g_Queue.front();

g_Queue.pop();

LeaveCriticalSection(&g_CriticalSection);

printf("Consumed data: %d\n", data);

if (data == 9)

{

break;

}

}

_endthreadex(0);

return 0;

}

int main()

{

InitializeCriticalSection(&g_CriticalSection);

InitializeConditionVariable(&g_ConditionVariable);

HANDLE hProducerThread = (HANDLE)_beginthreadex(NULL, 0, ProducerThreadProc, NULL, 0, NULL);

HANDLE hConsumerThread = (HANDLE)_beginthreadex(NULL, 0, ConsumerThreadProc, NULL, 0, NULL);

WaitForSingleObject(hProducerThread, INFINITE);

WaitForSingleObject(hConsumerThread, INFINITE);

CloseHandle(hProducerThread);

CloseHandle(hConsumerThread);

DeleteCriticalSection(&g_CriticalSection);

return 0;

}

2、在停止线程时的死锁问题

在使用条件变量停止线程时,死锁问题就像隐藏在程序深处的暗礁,稍不留意就会让整个程序陷入停滞的困境。我曾在开发一个实时数据处理系统时,遭遇过一次令人头疼的死锁。系统中有多个数据采集线程和数据处理线程,通过条件变量进行协同工作。当系统需要关闭时,主线程尝试停止所有工作线程。但问题出现了:某个数据处理线程正在等待条件变量满足,而此时主线程在释放相关资源前,没有正确通知这个线程,导致该线程一直处于等待状态,同时主线程又在等待这个线程结束后再继续释放资源,最终形成了死锁,程序就像被按下了暂停键,无法正常退出。

进一步剖析,死锁的形成往往源于线程间复杂的依赖关系和资源分配顺序不当。在多线程环境下,每个线程都持有或等待特定的资源,当这些资源的获取和释放顺序出现循环依赖时,死锁便悄然而至。例如,线程 A 持有资源 X 并等待资源 Y,线程 B 持有资源 Y 并等待资源 X,双方都不愿意释放已持有的资源,最终只能陷入僵局。

为了直观地理解,我们来看一段简化后的代码示例。假设存在两个线程,线程 ThreadA 和线程 ThreadB,它们通过条件变量和互斥锁协作,同时访问共享资源。

#include 
#include 
#include 

CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int sharedResource = 0;
BOOL isThreadAWaiting = FALSE;
BOOL isThreadBWaiting = FALSE;

// 线程A
unsigned int __stdcall ThreadAProc(void* pArguments) {
    EnterCriticalSection(&cs);
    isThreadAWaiting = TRUE;
    while (sharedResource == 0) {
        SleepConditionVariableCS(&cv, &cs, INFINITE);
    }
    // 对共享资源进行操作
    sharedResource--;
    printf("ThreadA: Processed sharedResource, current value: %d\n", sharedResource);
    isThreadAWaiting = FALSE;
    LeaveCriticalSection(&cs);
    _endthreadex(0);
    return 0;
}

// 线程B
unsigned int __stdcall ThreadBProc(void* pArguments) {
    EnterCriticalSection(&cs);
    isThreadBWaiting = TRUE;
    while (sharedResource < 10) {
        SleepConditionVariableCS(&cv, &cs, INFINITE);
    }
    // 对共享资源进行操作
    sharedResource += 5;
    printf("ThreadB: Processed sharedResource, current value: %d\n", sharedResource);
    isThreadBWaiting = FALSE;
    LeaveCriticalSection(&cs);
    _endthreadex(0);
    return 0;
}

int main() {
    InitializeCriticalSection(&cs);
    InitializeConditionVariable(&cv);

    HANDLE hThreadA = (HANDLE)_beginthreadex(NULL, 0, ThreadAProc, NULL, 0, NULL);
    HANDLE hThreadB = (HANDLE)_beginthreadex(NULL, 0, ThreadBProc, NULL, 0, NULL);

    // 模拟主线程的一些操作
    Sleep(1000);

    EnterCriticalSection(&cs);
    // 尝试停止线程,但未正确通知条件变量
    if (isThreadAWaiting || isThreadBWaiting) {
        // 这里如果没有正确调用WakeConditionVariable,就会导致死锁
    }
    LeaveCriticalSection(&cs);

    WaitForSingleObject(hThreadA, INFINITE);
    WaitForSingleObject(hThreadB, INFINITE);

    CloseHandle(hThreadA);
    CloseHandle(hThreadB);

    DeleteCriticalSection(&cs);

    return 0;
}

在上述代码中,如果主线程在尝试停止线程时,没有调用 WakeConditionVariable 通知等待的线程 ThreadA 和 ThreadB,那么 ThreadA 和 ThreadB 会一直等待条件变量满足,而主线程又在等待这两个线程结束,从而形成死锁。

为了避免这类死锁问题,我们可以采取多种策略。首先,在设计程序时,要明确线程间的依赖关系,确保资源的获取和释放顺序合理。例如,采用资源分配图算法,提前检测是否存在循环依赖的可能性。其次,在停止线程时,一定要保证正确地通知所有等待的条件变量。可以在主线程中,在决定停止线程时,先调用 WakeAllConditionVariable 函数,唤醒所有等待的线程,然后再进行资源释放等后续操作 。比如修改上述代码的主线程部分为:

int main() {
    InitializeCriticalSection(&cs);
    InitializeConditionVariable(&cv);

    HANDLE hThreadA = (HANDLE)_beginthreadex(NULL, 0, ThreadAProc, NULL, 0, NULL);
    HANDLE hThreadB = (HANDLE)_beginthreadex(NULL, 0, ThreadBProc, NULL, 0, NULL);

    // 模拟主线程的一些操作
    Sleep(1000);

    EnterCriticalSection(&cs);
    // 尝试停止线程,正确通知条件变量
    if (isThreadAWaiting || isThreadBWaiting) {
        WakeAllConditionVariable(&cv);
    }
    LeaveCriticalSection(&cs);

    WaitForSingleObject(hThreadA, INFINITE);
    WaitForSingleObject(hThreadB, INFINITE);

    CloseHandle(hThreadA);
    CloseHandle(hThreadB);

    DeleteCriticalSection(&cs);

    return 0;
}

七、一些有用的窍门和技巧

1、设置超时时间

在使用 SleepConditionVariableCS 等函数时,合理设置超时时间非常重要。这就像给等待中的线程一个 “闹钟”,避免其无限期等待。例如在一个网络请求相关的多线程程序中,线程等待服务器响应数据,设置一个合理的超时时间,当超过这个时间还未获取到数据时,线程可以进行错误处理,而不是一直等待下去占用资源。示例代码如下:

EnterCriticalSection(&cs);
while (conditionNotMet) {
    if (!SleepConditionVariableCS(&cv, &cs, 5000)) {  // 设置超时时间为5秒
        // 处理超时情况,比如进行错误记录或采取替代策略
        break;
    }
}
LeaveCriticalSection(&cs);

2、选择合适的通知方式

根据实际场景选择广播通知(BroadcastConditionVariable)还是单个通知(WakeConditionVariable)。如果多个线程等待同一个条件,且只要条件满足,所有等待线程都需要检查条件是否满足,这时使用广播通知更合适。比如在一个任务分发系统中,多个工作线程等待新任务的到来,当有新任务时,通过广播通知所有等待线程,让它们竞争获取任务。而当只有一个特定线程在等待某个条件时,使用单个通知即可,这样可以减少不必要的线程唤醒开销,提高程序效率。

3、条件判断与保护

在等待条件变量之前,一定要在临界区内进行条件判断,并且使用循环来检查条件是否满足。这是因为线程被唤醒后,可能由于其他线程的操作,条件仍然不满足。就像在一场接力比赛中,接力棒传递到等待的队员手中时,队员还需要确认比赛是否可以继续进行(条件是否满足)。示例代码如下:

EnterCriticalSection(&cs);
while (!someCondition) {
    SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// 对共享资源进行操作
LeaveCriticalSection(&cs);

4、结合其他同步机制

条件变量很少单独使用,通常会和关键段、互斥锁等同步机制结合。在使用时,要注意它们之间的配合。例如,在进入条件变量等待前,先获取关键段或互斥锁,确保在等待过程中共享资源不会被其他线程非法访问;在唤醒线程后,也要合理处理锁的释放和重新获取,保证线程安全。

最后小结:

想想那些年在 Windows 开发中与线程同步 “斗智斗勇” 的日子,每一次解决死锁问题、优化同步机制,都像是在攻克一座技术堡垒。这些经验不仅让我在代码的世界里更加游刃有余,也让我深刻体会到,技术的魅力就在于不断探索未知,在解决问题的过程中实现自我突破 。

你可能感兴趣的:(熬之滴水穿石,单片机,stm32,嵌入式硬件,windows)