C#中使用get/set实现线程安全

62

这是一个关于C#的详细问题。

假设我有一个带有一个对象的类,并且该对象受到锁的保护:

Object mLock = new Object();
MyObject property;
public MyObject MyProperty {
    get {
         return property;
    }
    set { 
         property = value; 
    }
}
我希望创建一个轮询线程以查询该属性。我还希望该线程偶尔更新该对象的属性,有时用户可以更新该属性,并且用户希望能够查看该属性。以下代码是否能正确锁定数据?
Object mLock = new Object();
MyObject property;
public MyObject MyProperty {
    get {
         lock (mLock){
             return property;
         }
    }
    set { 
         lock (mLock){
              property = value; 
         }
    }
}

所谓“properly”,我指的是,如果我想调用

MyProperty.Field1 = 2;

无论是哪种情况,在我进行更新时,该字段是否会被锁定?等于操作符所做的设置是在“get”函数作用域内完成的吗,还是“get”函数(因此是锁定)会先完成,然后是设置,然后调用“set”,从而绕过锁定?

编辑:由于这显然行不通,那么该怎么办?我需要做类似以下的事情吗:

Object mLock = new Object();
MyObject property;
public MyObject MyProperty {
    get {
         MyObject tmp = null;
         lock (mLock){
             tmp = property.Clone();
         }
         return tmp;
    }
    set { 
         lock (mLock){
              property = value; 
         }
    }
}

这个代码片段使用了一个锁来保证多个线程同时读取某个属性时能够获取到同样的值,但是这只是保证了访问属性的行为正确,而并没有对属性进行加锁保护。如果需要对该属性进行同时读写的保护,应该如何实现呢?是直接对属性进行加锁,还是只对函数部分进行加锁呢?

举个例子说明一下:MyObject是一个异步返回状态信息的设备驱动程序。我通过串口发送命令给它,并等待它响应命令。目前有三个线程,一个轮询设备状态的线程(“你还在吗?可以接受命令吗?”),一个等待串口响应的线程(“刚刚收到状态字符串2,一切正常”),以及一个处理用户输入输出的UI线程(“用户希望你做这件事情”、“我刚刚完成了那件事情,现在更新一下UI”)。因此,我们需要对对象本身进行加锁,而不是对对象中的字段进行加锁。如果对每个字段都进行加锁,那么需要大量的锁,并且由于不同设备具有不同的行为,需要编写大量的单独的对话框。

9个回答

46
不,你的代码不会锁定从MyProperty返回的对象的成员。它只会锁定MyProperty本身。
你的示例用法实际上是两个操作合并为一个,大致等同于这个:
// object is locked and then immediately released in the MyProperty getter
MyObject o = MyProperty;

// this assignment isn't covered by a lock
o.Field1 = 2;

// the MyProperty setter is never even called in this example

简而言之,如果两个线程同时访问MyProperty,getter方法将会短暂地阻塞第二个线程,直到它将对象返回给第一个线程,但是它也会将对象返回给第二个线程。然后,两个线程都可以完全解锁地访问该对象。 编辑以回应问题中的更多细节 我仍然不确定您想要实现什么,但如果您只想原子地访问该对象,那么您能否让调用代码针对该对象本身进行锁定?
// quick and dirty example
// there's almost certainly a better/cleaner way to do this
lock (MyProperty)
{
    // other threads can't lock the object while you're in here
    MyProperty.Field1 = 2;
    // do more stuff if you like, the object is all yours
}
// now the object is up-for-grabs again

虽然不是最理想的方法,但只要所有对该对象的访问都包含在 lock (MyProperty) 区块中,这种方法就是线程安全的。


同意。我针对一个Singleton问题提供了一个样例解决方案,链接在此:https://dev59.com/MHVD5IYBdhLWcg3wWaRh#7105 - Zooba
是的,我可能会按照你所描述的方式锁定对象。谢谢。 - mmr
不错的观点,但是如果他有一个名为pMyProperty并同时调用两个线程:p = new MyObject(12)p = new MyObject(5),那么访问将被同步。然而,Field1成员将永远不会被锁定。我很确定这就是你所说的,只是想自己理解一下。 - Snoop

17

如果你的方法可以起作用,那么并发编程将会变得非常简单。但事实并非如此,例如你的类的客户端执行以下操作时,会导致冰山撞击泰坦尼克号:

objectRef.MyProperty += 1;

读-修改-写竞争很明显,还有更糟糕的情况。除了使其不可变之外,您无法采取任何措施使您的属性线程安全。需要处理这种责任的是您的客户端程序,而这种责任被委托给可能最不可能正确处理它的程序员是并发编程的致命弱点。


如果能这么简单就太棒了,不是吗?我相信肯定有原因,但我不知道具体细节,也不知道为什么代码不能这样工作。 - mmr
重要的是要明白这一点,如果你不懂并发编程,请不要尝试。请搜索“原子更新”。 - Hans Passant
我确实理解原子性,但是我对C#中哪些地方是原子的还有点模糊。只有基本类型赋值是原子的,对于像Interlocked这样指定原子性可能会很有用... - mmr

6
如其他人所指出的那样,一旦你从getter返回对象,你就失去了控制谁何时访问该对象的控制权。为了实现你想要的功能,你需要在对象本身内部放置一个锁。
也许我没有完全理解情况,但根据你的描述,似乎不一定需要为每个单独字段都设置锁。如果你有一组通过getter和setter简单读写的字段,你可能可以只使用一个锁来控制这些字段。显然,以这种方式序列化线程操作会导致你不必要地减缓线程的执行速度,但是又根据你的描述,貌似你也没有过度频繁地访问该对象。
我还建议使用事件(event)而不是线程来轮询设备状态。使用轮询机制,每次线程查询设备时都会触发锁。而使用事件机制,一旦状态更改,对象将通知任何侦听器。此时,您的“轮询”线程(它将不再进行轮询)将唤醒并获取新状态。这样更加有效率。
例如 ...
public class Status
{
    private int _code;
    private DateTime _lastUpdate;
    private object _sync = new object(); // single lock for both fields

    public int Code
    {
        get { lock (_sync) { return _code; } }
        set
        {
            lock (_sync) {
                _code = value;
            }

            // Notify listeners
            EventHandler handler = Changed;
            if (handler != null) {
                handler(this, null);
            }
        }
    }

    public DateTime LastUpdate
    {
        get { lock (_sync) { return _lastUpdate; } }
        set { lock (_sync) { _lastUpdate = value; } }
    }

    public event EventHandler Changed;
}

您的“轮询”线程可能如下所示。

您的“轮询”线程可能如下所示。

Status status = new Status();
ManualResetEvent changedEvent = new ManualResetEvent(false);
Thread thread = new Thread(
    delegate() {
        status.Changed += delegate { changedEvent.Set(); };
        while (true) {
            changedEvent.WaitOne(Timeout.Infinite);
            int code = status.Code;
            DateTime lastUpdate = status.LastUpdate;
            changedEvent.Reset();
        }
    }
);
thread.Start();

有趣。我得去看看这个。我的第一印象是它不起作用,因为我使用的设备除非被要求,否则不会发出状态信息。所以如果处于“准备”模式,除非询问,否则没有任何指示。 - mmr
是的,如果你必须要激活设备来报告状态,那么轮询可能是你唯一能做的事情。不过,有没有任何回调机制可以让设备定期报告状态呢?我讨厌轮询因为它效率低下,但有时候你别无选择。 - Matt Davis
不幸的是,它是一个真正的串行设备——它只响应轮询。这不是USB,而是9针。我想这就是老派复古的全部。 - mmr

2
您的示例中锁定范围放置位置不正确 - 它需要在'MyObject'类属性的作用域内,而不是其容器中。
如果MyObject类仅用于包含一个线程要写入的数据,另一个线程(UI线程)要从中读取,则可能根本不需要setter,并且可以构造一次。
还要考虑是否将锁定放置在属性级别是正确的锁定粒度级别;如果为了表示事务状态而必须写入多个属性(例如:总订单和总重量),则最好将锁定放置在MyObject级别上(即lock(myObject.SyncRoot)...)。

1
多线程的美妙之处在于你无法确定事情发生的顺序。如果你在一个线程上设置了某个东西,它可能会先发生,也可能会在获取之后发生。
你发布的代码会在读取和写入时锁定成员。如果你想处理值更新的情况,或许你应该考虑其他形式的同步,比如事件(查看自动/手动版本)。然后你可以告诉你的“轮询”线程值已经改变,准备好重新读取。

1
在您发布的代码示例中,从未执行get操作。
在一个更复杂的示例中:
MyProperty.Field1 = MyProperty.doSomething() + 2;

当然,假设你已经执行了:

lock (mLock) 
{
    // stuff...
}

doSomething()中,所有的锁调用都不足以保证整个对象的同步。一旦doSomething()函数返回,锁就会丢失,然后进行加法操作,然后进行赋值操作,再次进行锁定。
或者,换句话说,你可以假装锁没有自动完成,将其重写为更像是“机器代码”的形式,每行只有一个操作,这样就变得很明显了:
lock (mLock) 
{
    val = doSomething()
}
val = val + 2
lock (mLock)
{
    MyProperty.Field1 = val
}

或许我对属性的理解有误,但是调试器看起来确实在步入“get”函数,尤其是当我在那里设置断点进行简单赋值时。 - mmr
我承认我不确定它是否会调用get函数,但我看不出为什么会这样。如果它确实调用了get函数,那么我说的同样适用,只需将doSomething()替换为您的get函数即可。 - SoapBox
它确实每次都会调用get。否则,引用从哪里来呢? - Rex M
但这不是预期的行为吗?应该:i = i + 1; 增加 i 吧? - dmo
哦,我想我完全误读了这个问题。我以为他的示例的getter和setter都是在Field1属性上定义的,而不是在MyProperty对象本身上。 - SoapBox

0
在您的编辑版本中,仍未提供一种线程安全的方式来更新MyObject。对象属性的任何更改都需要在同步/锁定块内完成。
您可以编写单独的setter来处理此问题,但是由于字段数量很大,您已经表明这将是困难的。如果确实如此(您尚未提供足够的信息来评估此问题),则另一种选择是编写使用反射的setter;这将允许您传递表示字段名称的字符串,并且您可以动态查找字段名称并更新值。这将允许您拥有一个单一的setter,可用于任意数量的字段。这不像简单或高效,但它将允许您处理大量的类和字段。

0

-2

C#锁定是否不像其他语言那样遭受锁定问题的困扰呢?

例如:

var someObj = -1;

// Thread 1

if (someObj = -1)
    lock(someObj)
        someObj = 42;

// Thread 2

if (someObj = -1)
    lock(someObj)
        someObj = 24;

这可能存在一个问题,即两个线程最终都获取了锁并更改了值。这可能会导致一些奇怪的错误。但是,除非必要,否则不要无谓地锁定对象。在这种情况下,您应该考虑双重检查锁定。

// Threads 1 & 2

if (someObj = -1)
    lock(someObj)
        if(someObj = -1)
            someObj = {newValue};

这只是需要记住的一些事情。


请注意,锁定您要修改的对象导致死锁。 - SimonC

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