C++的临界区用于getter方法

4

我有一个简单的类,只有一个私有成员,可以通过get()和set()在多线程环境(多读/多写)中访问。如何锁定Get()方法,因为它只有一个返回语句?

class MyValue
{
  private:
    System::CriticalSection lock;
    int val { 0 };

  public:
    int SetValue(int arg)
    {
        lock.Enter();
        val = arg;
        lock.Leave();
    }

    int GetValue()
    {
        lock.Enter();
        return val;
        //Where should I do lock.Leave()?
    }   
}

如果被频繁调用,可以使用原子变量。如果没有C++可用,则在Windows上有像Interlock家族这样的系统函数。 - knivil
4个回答

3
不要加锁。在你的示例中,如果将成员变量设置为std::atomic整数就足够了。
这里不需要其他任何东西。事实上,由于英特尔架构(强内存排序模型),这个std::atomic甚至不太可能引起任何性能问题。

真的吗?哇。我会查一下的。这个对象的上下文是否重要?因为它作为无序映射的本地实现中的“值”存在。所以即使有多个读取器和多个写入器线程同时访问它,它也能正常工作? - bsobaid
@bsobaid,对象存储的位置并不重要。你正在将成员变量设为原子操作,这意味着每次读取时都应该正确地读取(以便看到最新的更新),并且在写入时必须提交写入操作。它还禁止重新排序。所以你很酷 ;) - SergeyA
我正在使用VS2013。我需要包含什么吗?Intellisense在std::后没有显示atomic。它向我显示的是std::_Atomic_integral_t,但我相信这是不同的东西。 - bsobaid
GetVal()和SetVal()的返回类型和参数类型也应该改为std::atomic_int,或者我认为可以保持为int? - bsobaid
不,事实上,你无法将它们变成原子性的。它们需要保持为整数。 - SergeyA

2

我不是多线程专家,但我认为以下方法应该可行。

int GetValue()
{
    lock.Enter();
    int ret = val;
    lock.Leave();
    return ret;
}   

但如果线程A正在执行"return ret",而线程B正在执行int ret = val ??,那该怎么办? - bsobaid
如果我正确理解多线程,每个线程在执行函数时都会有自己的所有局部变量副本(在这种情况下是 int ret),因此这不会成为问题。 - HolyBlackCat
@bsobaid,那么你将会进行脏读取(Google it:当一个事务被允许从已经被另一个运行的事务修改但尚未提交的行中读取数据时,就会发生脏读(即未提交依赖))。 - hauron
实际上,我可以接受脏读取(dirty read)。比如线程 A 读取 val 的值为 10,当它在返回 ret 时,线程 B 来并将 val 设置为 20。在这种情况下,线程 A 会有一个过期的值。我可以接受这种情况。 - bsobaid
根据您系统的架构,您可以使用共享的 int 值而不需要锁定,这样您就不会得到垃圾值。 - Jts
2
@bsobaid @José @hauron 但是每个线程都有自己的堆栈。每个线程都会有自己的 int ret; 副本,因此“脏读”将不可能发生。或者我理解错了吗? - HolyBlackCat

2
这是一个关于hauron回答中同步对象的演示--我想展示当使用优化后的构建时,对象构造和销毁开销根本不存在。
在下面的代码中,CCsGrabber是类似RAII的类,当它被构造时进入临界区(由CCritical对象包装),然后在销毁时离开。
class CCsGrabber {
    class CCritical& m_Cs;
    CCsGrabber();
public:
    CCsGrabber(CCritical& cs);
    ~CCsGrabber();
};

class CCritical {
    CRITICAL_SECTION cs;
public:
    CCritical()       { 
        InitializeCriticalSection(&cs); 
    }
    ~CCritical()      { DeleteCriticalSection(&cs); }
    void Enter()      { EnterCriticalSection(&cs); }
    void Leave()      { LeaveCriticalSection(&cs); }
    void Lock()       { Enter(); }
    void Unlock()     { Leave(); }
};

inline CCsGrabber::CCsGrabber(CCritical& cs)  : m_Cs(cs)   { m_Cs.Enter(); }
inline CCsGrabber::CCsGrabber(CCritical *pcs) : m_Cs(*pcs) { m_Cs.Enter(); }
inline CCsGrabber::~CCsGrabber()                           { m_Cs.Leave(); }

现在创建了一个全局的CCritical对象(cs),它与本地的CCsGrabber实例(csg)一起在SerialFunc()中使用,以处理锁定和解锁:
CCritical cs;
DWORD last_tick = 0;

void SerialFunc() {
    CCsGrabber csg(cs);
    last_tick = GetTickCount();
}

int main() {
    SerialFunc();
    std::cout << last_tick << std::endl;
}

以下是经过优化的32位版本中main()函数的反汇编代码。(抱歉我粘贴了整个代码,我想表明我没有隐瞒任何信息):

int main() {
00401C80  push        ebp  
00401C81  mov         ebp,esp  
00401C83  and         esp,0FFFFFFF8h  
00401C86  push        0FFFFFFFFh  
00401C88  push        41B038h  
00401C8D  mov         eax,dword ptr fs:[00000000h]  
00401C93  push        eax  
00401C94  mov         dword ptr fs:[0],esp  
00401C9B  sub         esp,0Ch  
00401C9E  push        esi  
00401C9F  push        edi  
    SerialFunc();
00401CA0  push        427B78h                          ; pointer to CS object
00401CA5  call        dword ptr ds:[41C00Ch]           ; _RtlEnterCriticalSection@4:
00401CAB  call        dword ptr ds:[41C000h]           ; _GetTickCountStub@0:
00401CB1  push        427B78h                          ; pointer to CS object
00401CB6  mov         dword ptr ds:[00427B74h],eax     ; return value => last_tick
00401CBB  call        dword ptr ds:[41C008h]           ; _RtlLeaveCriticalSection@4: 
    std::cout << last_tick << std::endl;
00401CC1  push        ecx  
00401CC2  call        std::basic_ostream<char,std::char_traits<char> >::operator<< (0401D90h)  
00401CC7  mov         esi,eax  
00401CC9  lea         eax,[esp+0Ch]  
00401CCD  push        eax  
00401CCE  mov         ecx,dword ptr [esi]  
00401CD0  mov         ecx,dword ptr [ecx+4]  
00401CD3  add         ecx,esi  
00401CD5  call        std::ios_base::getloc (0401BD0h)  
00401CDA  push        eax  
00401CDB  mov         dword ptr [esp+20h],0  
00401CE3  call        std::use_facet<std::ctype<char> > (0403E40h)  
00401CE8  mov         dword ptr [esp+20h],0FFFFFFFFh  
00401CF0  add         esp,4  
00401CF3  mov         ecx,dword ptr [esp+0Ch]  
00401CF7  mov         edi,eax  
00401CF9  test        ecx,ecx  
00401CFB  je          main+8Eh (0401D0Eh)  
00401CFD  mov         edx,dword ptr [ecx]  
00401CFF  call        dword ptr [edx+8]  
00401D02  test        eax,eax  
00401D04  je          main+8Eh (0401D0Eh)  
00401D06  mov         edx,dword ptr [eax]  
00401D08  mov         ecx,eax  
00401D0A  push        1  
00401D0C  call        dword ptr [edx]  
00401D0E  mov         eax,dword ptr [edi]  
00401D10  mov         ecx,edi  
00401D12  push        0Ah  
00401D14  mov         eax,dword ptr [eax+20h]  
00401D17  call        eax  
00401D19  movzx       eax,al  
00401D1C  mov         ecx,esi  
00401D1E  push        eax  
00401D1F  call        std::basic_ostream<char,std::char_traits<char> >::put (0404220h)  
00401D24  mov         ecx,esi  
00401D26  call        std::basic_ostream<char,std::char_traits<char> >::flush (0402EB0h)  
}
00401D2B  mov         ecx,dword ptr [esp+14h]  
00401D2F  xor         eax,eax  
00401D31  pop         edi  
00401D32  mov         dword ptr fs:[0],ecx  
00401D39  pop         esi  
00401D3A  mov         esp,ebp  
00401D3C  pop         ebp  
00401D3D  ret  

所以我们可以看到,在主函数的开头进行了序言操作之后,SerialFunc()直接被内联进了代码中,而在cout代码之前 -- 没有多余的对象创建、内存分配等任何操作 -- 它看起来只是进入临界区域所需的最小汇编代码,将tick计数存储到一个变量中,然后离开临界区域。

接着,我把SerialFunc()改成了:

void SerialFunc() {
    cs.Enter();
    last_tick = GetTickCount();
    cs.Leave();
}

使用显式放置的cs.Enter()cs.Leave(),与RAII版本进行比较。生成的代码结果相同:

    int main() {
00401C80  push        ebp  
00401C81  mov         ebp,esp  
00401C83  and         esp,0FFFFFFF8h  
00401C86  push        0FFFFFFFFh  
00401C88  push        41B038h  
00401C8D  mov         eax,dword ptr fs:[00000000h]  
00401C93  push        eax  
00401C94  mov         dword ptr fs:[0],esp  
00401C9B  sub         esp,0Ch  
00401C9E  push        esi  
00401C9F  push        edi  
        SerialFunc();
00401CA0  push        427B78h  
00401CA5  call        dword ptr ds:[41C00Ch]  
00401CAB  call        dword ptr ds:[41C000h]  
00401CB1  push        427B78h  
00401CB6  mov         dword ptr ds:[00427B74h],eax  
00401CBB  call        dword ptr ds:[41C008h]  
        std::cout << last_tick << std::endl;
00401CC1  push        ecx  
00401CC2  call        std::basic_ostream<char,std::char_traits<char> >::operator<< (0401D90h)  
                         ...

在我看来,SergeyA的答案对于给定的情况是最好的——为了同步读写32位变量而使用关键段是过度的。然而,如果出现需要关键段或互斥量的情况,使用类似RAII的对象简化代码可能不会产生重大(甚至任何)对象创建开销。

(我使用Visual C++ 2013编译上面的代码)


CRITSEC_OBJECT 定义在哪里? - bsobaid
抱歉-- CRITSEC_OBJECT只是我在粘贴代码时错过的CRITICAL_SECTION的typedef。我进行了修改,用CRITICAL_SECTION替换了它。 - Christopher Oicles
请问您的临界区和System::CriticalSection有什么区别?System::CriticalSection具有成员函数Enter()和Leave(),而EnterCriticalSection()和LeaveCriticalSection()则不具备。我认为您的是C风格的,而System::CriticalSection则是C++风格的? - bsobaid
@bsobaid CCritical是一个C++类,它管理Win32临界区的生命周期,并提供与API调用相对应的成员函数,这些函数作用于所拥有的临界区对象。它为Win32 API的C语言接口提供了一个C++包装器。我不熟悉名为System::CriticalSection的类,但它听起来像是一个类似的C++包装器,用于底层Win32临界区对象及其函数。 - Christopher Oicles

1
考虑使用类包装器,在构造函数中锁定,在析构函数中解锁。请参见标准实现: http://en.cppreference.com/w/cpp/thread/unique_lock 这样,即使在代码复杂或代码中抛出异常并改变正常执行的情况下,您也不需要记住解锁。

@bsobaid 请参考此链接:https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock(有关实现,请参见boost的upgrade lock:一种可以“升级”为唯一访问权限的互斥锁)。 - hauron
我正在使用VS2013。互斥锁是否会很昂贵,因为它进入内核模式,而临界区在未被争用时仍保留在用户模式下? - bsobaid
@bsobaid,是的,在Windows上,临界区比互斥量快得多。但是,你都不需要用。 - SergeyA
@M.M 如果 std::mutex 使用 criticalsection(因此仍然保留在用户模式下??),那么这样的 mutex 如何可以在进程内工作?因为这是 mutex 的基本特征。 - bsobaid
1
在标准C++中,没有进程间通信,只有线程间通信。要进行进程间同步,必须使用操作系统提供的功能。 - M.M
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接