C# 手动锁定/解锁

23

我有一个C#函数,可以从多个线程多次调用,但我希望它只执行一次,所以我想到了这个:

class MyClass
{
    bool done = false;
    public void DoSomething()
    {
        lock(this)
            if(!done)
            {
                done = true;
                _DoSomething();
            }
    }
}

问题在于_DoSomething需要很长时间,我不希望许多线程等待它,而这些线程可以看到done为真。
可以使用类似以下代码的解决方法:

class MyClass
{
    bool done = false;
    public void DoSomething()
    {
        bool doIt = false;
        lock(this)
            if(!done)
                doIt = done = true;
        if(doIt)
             _DoSomething();
    }
}

但仅手动执行锁定和解锁将会更好。
我应该如何手动执行锁定和解锁,就像使用 lock(object) 一样? 我需要使用与 lock 相同的接口进行操作,以便这种手动方式和 lock 会互相阻塞(对于更复杂的情况)。


仔细考虑使用lock(this)的缺点:为什么lock(this)是不好的? - Cody Gray
4
我感到困惑。如果多个线程长时间等待_DoSomething,这不是你想要的吗?如果两个线程同时说“同步确保已调用_DoSomething”,那么它们都必须等待至少一个线程完成调用,不是吗?为什么会有问题?等待直到完成听起来像是期望中的功能。你能解释一下吗? - Eric Lippert
此外,你能解释一下为什么你认为在使用监视器时显式地进行锁定和解锁比使用 lock 语句更好吗? - Eric Lippert
1
@Dani:如果它没有完成,其他线程应该做什么,继续执行就好了吗?这是有问题的,因为那样它就没有被初始化,而它们却在执行它已经被初始化的代码。 - Greg D
4
@Greg:这取决于“DoSomething”实际执行的操作。例如,假设“DoSomething”是“在服务启动时向管理员发送电子邮件”。这是一项你只希望执行一次的操作。如果十个线程同时尝试发送电子邮件,其中九个可以立即返回,如果第十个正在解决问题,则假设“正在解决问题”就足够了,你不必等待其完成。由于原始帖子未告诉我们DoSomething正在执行何种操作,因此很难对代码进行正确的分析。 - Eric Lippert
显示剩余2条评论
6个回答

35

lock关键字只是Monitor.Enter和Monitor.Exit的语法糖:

Monitor.Enter(o);
try
{
    //put your code here
}
finally
{
    Monitor.Exit(o);
}

与...相同

lock(o)
{
    //put your code here
}

20
请注意,在C# 4中,我们稍微改变了代码生成方式,以使enter语句进入try块内。有关详细信息,请参见http://blogs.msdn.com/b/ericlippert/archive/2009/03/06/locks-and-exceptions-do-not-mix.aspx。 - Eric Lippert
@EricLippert 谢谢。我刚刚修改了答案。 - PVitt
4
修改使得代码不正确。假设在进入try块但获取锁之前抛出了线程中止异常,那么finally块将会运行并释放一个从未获取的锁。 - Eric Lippert

30

Thomas在他的回答中建议使用双重检查锁定。这是有问题的。首先,除非您已经证明低锁定技术可以解决真正的性能问题,否则不应使用低锁定技术。低锁定技术异常难以正确实现。

第二,这有问题,因为我们不知道“_DoSomething”做了什么或者我们将依赖其行为的后果。

第三,正如我在以上评论中指出的那样,如果另一个线程仍在执行_DoSomething的任务,似乎返回“done”是不合理的。我不明白为什么您会有这个要求,并且我会认为这是一个错误。即使我们在“_DoSomething”完成任务后设置“done”,该模式仍存在问题。

考虑以下内容:

class MyClass 
{
     readonly object locker = new object();
     bool done = false;     
     public void DoSomething()     
     {         
        if (!done)
        {
            lock(locker)
            {
                if(!done)
                {
                    ReallyDoSomething();
                    done = true;
                }
            }
        }
    }

    int x;
    void ReallyDoSomething()
    {
        x = 123;
    }

    void DoIt()
    {
        DoSomething();
        int y = x;
        Debug.Assert(y == 123); // Can this fire?
    }

在所有可能的C#实现中,这是线程安全的吗?我认为不是。请记住,非易失性读操作可能会被处理器缓存在时间上移动。 C#语言保证易失性读操作与关键执行点(如锁)一致排序,并保证非易失性读操作在单个执行线程内一致,但它保证非易失性读操作在任何情况下在线程之间保持一致。

让我们来看一个例子。

假设有两个线程,Alpha和Bravo。它们都在MyClass的新实例上调用DoIt。会发生什么?

在Bravo线程上,处理器缓存恰好对x的内存位置进行了(非易失性!)获取,该位置包含零。“done”恰好位于另一页未被缓存的内存中。

在Alpha线程上,与此“同时”在另一处理器上,DoIt调用DoSomething。线程Alpha现在运行其中的所有内容。当线程Alpha完成其工作时,done为true,Alpha处理器上的x为123。线程Alpha的处理器刷新了这些事实并输出到主内存。

现在线程bravo运行DoSomething。它将包含“done”的主内存页面读入处理器缓存,并查看它为true。

因此现在“done”为true,但是在线程Bravo的处理器缓存中,“x”仍然为零。线程Bravo不需要使包含“x”的缓存部分无效,因为在线程Bravo上,“done”的读取操作和“x”的读取操作都不是易失性读操作。

提出的双重检查锁定版本实际上根本不是双重检查锁定。当您更改双重检查锁定模式时,您需要从头开始重新分析所有内容。

使此模式的版本正确的方法是将“done”的至少第一次读取更改为易失性读取。然后,“x”的读取将不被允许比“done”的易失性读取“超前”。


Eric,你认为应该如何执行volatile读取操作呢?使用Thread.VolatileRead吗? - Thomas Levesque
7
@Thomas:我建议除非你叫Joe Duffy,否则永远不要使用双重检查锁。如果您确实想要冒险并发明自己的双重检查锁版本,那么您可能不想使用Thread.VolatileRead,因为它可能具有糟糕的性能特征 - 在某些实现中,我相信它会创建完整的内存屏障,尽管我可能记错了。您可能只需使“done”变量成为volatile,即使这样做会使得它的一些读写操作不必要地变得volatile。 - Eric Lippert
你关于volatile和内存共享的解释帮助我搞清楚了其他一些bug,但是其他线程不需要使用ReallyDoSomething生成的任何信息。 - Daniel

4

在锁定之前之后,您可以检查done的值:

    if (!done)
    {
        lock(this)
        {
            if(!done)
            {
                done = true;
                _DoSomething();
            }
        }
    }

如果done为true,那么您就不会进入锁。锁内的第二次检查是为了处理两个线程同时进入第一个if时的竞争条件。

顺便说一下,您不应该在this上加锁,因为它可能会导致死锁。相反,应该在私有字段上加锁(例如private readonly object _syncLock = new object())。


请注意,双重检查锁定在某些硬件/软件架构中存在问题。在CLR中,它被正确实现并允许这种用法。 - vgru
1
看起来不错。一个注意点是... done 需要标记为 volatile - Brian Gideon
2
lock 创建了一个内存屏障,因此跳过 volatile 不会导致奇怪的错误,只可能导致性能下降:没有 volatile,代码可能会进入外部的 if (!done) 部分并争夺锁,而实际上它并不需要。一旦获取到锁,那么代码将在内部的 if (!done) 检查中看到最新值。 - LukeH
1
@Groo,@LukeH,@Thomas:我不相信你们任何一个人的分析是正确的。@Brian:done不需要被标记为volatile,尽管这可以解决问题。必须使用volatile语义来执行对done的第一次读取。第二次读取或写入不需要是volatile的。 - Eric Lippert
2
@Groo:为什么“只有”这种情况会发生 既然我们既没有展示_DoSomething的实现,也没有展示调用者的实现?是什么阻止了调用者访问DoSomething改变过的内存?又是什么阻止了那些非易失性内存访问在时间上被移动到“done”第一次读取之前的某个时刻,而该线程从未进入锁(因为“done”为真)?我不认为任何这些事情被阻止了。但是,我对这个问题并不是专家;如果你想向我展示我的分析中有错误的地方,我将不胜感激。 - Eric Lippert
显示剩余6条评论

2
我知道这个答案来得有些晚,但是目前的回答似乎都没有解决你实际遇到的情况,这个情况只在你的评论之后才变得明显:

其他线程不需要使用ReallyDoSomething生成的任何信息。

如果其他线程不需要等待操作完成,那么你问题中的第二段代码片段就可以正常工作。你可以进一步优化它,完全消除锁定并改用原子操作:
private int done = 0;
public void DoSomething()
{
    if (Interlocked.Exchange(ref done, 1) == 0)   // only evaluates to true ONCE
        _DoSomething();
}

此外,如果您的_DoSomething()是一个“fire-and-forget”操作,那么您甚至不需要第一个线程等待它,让它在线程池中异步运行一个任务即可:
int done = 0;

public void DoSomething()
{
    if (Interlocked.Exchange(ref done, 1) == 0)
        Task.Factory.StartNew(_DoSomething);
}

2

0
因为我遇到了类似的问题,但可能需要多次函数调用,这些调用应该在锁定块内部决定,但在解锁后执行。所以我实现了一个相当通用的方法:
    delegate void DoAfterLock(); // declare this outside the function
    public void MyFunction()
    {
        DoAfterLock doAfterLock = null;
        lock (_myLockObj)
        {
            if (someCondition)
                doAfterLock = () => DoSomething1(localParameters....);
            if (someCondition2)
                doAfterLock = () => DoSomething2(localParameters....);
        }

        doAfterLock?.Invoke();
    }

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