C#中ThreadStatic + volatile成员不按预期工作

6
我正在阅读技巧和窍门帖子,想尝试一些以前从未做过的C#编程内容。因此,以下代码没有实际用途,只是一个“测试函数”,用于查看发生了什么。
无论如何,我有两个静态私有字段:
private static volatile string staticVolatileTestString = "";
[ThreadStatic]
private static int threadInt = 0;

你可以看到,我正在测试 ThreadStaticAttribute 和 volatile 关键字。
无论如何,我有一个测试方法像这样:
private static string TestThreadStatic() {
    // Firstly I'm creating 10 threads (DEFAULT_TEST_SIZE is 10) and starting them all with an anonymous method
    List<Thread> startedThreads = new List<Thread>();
    for (int i = 0; i < DEFAULT_TEST_SIZE; ++i) {
        Thread t = new Thread(delegate(object o) {
            // The anon method sets a newValue for threadInt and prints the new value to the volatile test string, then waits between 1 and 10 seconds, then prints the value for threadInt to the volatile test string again to confirm that no other thread has changed it
            int newVal = randomNumberGenerator.Next(10, 100);
            staticVolatileTestString += Environment.NewLine + "\tthread " + ((int) o) + " setting threadInt to " + newVal;
            threadInt = newVal;
            Thread.Sleep(randomNumberGenerator.Next(1000, 10000));
            staticVolatileTestString += Environment.NewLine + "\tthread " + ((int) o) + " finished: " + threadInt;
        });
        t.Start(i);
        startedThreads.Add(t);
    }

    foreach (Thread th in startedThreads) th.Join();

    return staticVolatileTestString;
}

我希望看到这个函数返回的输出如下所示:
thread 0 setting threadInt to 88
thread 1 setting threadInt to 97
thread 2 setting threadInt to 11
thread 3 setting threadInt to 84
thread 4 setting threadInt to 67
thread 5 setting threadInt to 46
thread 6 setting threadInt to 94
thread 7 setting threadInt to 60
thread 8 setting threadInt to 11
thread 9 setting threadInt to 81
thread 5 finished: 46
thread 2 finished: 11
thread 4 finished: 67
thread 3 finished: 84
thread 9 finished: 81
thread 6 finished: 94
thread 7 finished: 60
thread 1 finished: 97
thread 8 finished: 11
thread 0 finished: 88

然而,我得到的是这个:
thread 0 setting threadInt to 88
thread 4 setting threadInt to 67
thread 6 setting threadInt to 94
thread 7 setting threadInt to 60
thread 8 setting threadInt to 11
thread 9 setting threadInt to 81
thread 5 finished: 46
thread 2 finished: 11
thread 4 finished: 67
thread 3 finished: 84
thread 9 finished: 81
thread 6 finished: 94
thread 7 finished: 60
thread 1 finished: 97
thread 8 finished: 11
thread 0 finished: 88

第二个“半部分”的输出符合预期(我想这意味着ThreadStatic字段正在像我想的那样工作),但似乎一些初始输出已经从第一个“半部分”中被“跳过”。
此外,第一个“半部分”的线程是无序的,但我理解当你调用Start()时,线程不会立即运行;而是内部操作系统控制将根据需要启动线程。编辑:实际上不是这样,我只是以为它们是因为我的大脑错过了连续的数字。
所以,我的问题是:是什么出了问题导致我在输出的前半部分丢失了几行?例如,'线程3将threadInt设置为84'这一行在哪里?

我运行了你的代码几次,每次都得到了预期的输出... - Cheng Chen
我敢打赌,在某个地方,字符串上的 += 调用在前一个线程设置值之前获取了该值,并且新线程正在使用新值覆盖它(因此线程1的输出被跳过了)。 - Charleh
这与四核无关。同样的情况也可能发生在单核上。这取决于调度程序何时决定暂停/恢复线程。它可能在四核上发生得更频繁,但也可能相反... - Philip Daubmeier
@Charleh 你编辑了你的评论:是的,我认为这就是发生的事情,Lucero的答案很好地解决了这个问题。 - Xenoprimate
是的,我意识到这不是volatile的作用,我的大脑瞬间爆炸了。 - Charleh
显示剩余2条评论
2个回答

8

线程是在同时执行。概念上发生的事情是这样的:

staticVolatileTestString += Environment.NewLine + "\tthread " + ((int) o) + " setting threadInt to " + newVal;
  1. 线程1读取staticVolatileTestString
  2. 线程2读取staticVolatileTestString
  3. 线程3读取staticVolatileTestString
  4. 线程1添加内容并写回staticVolatileTestString
  5. 线程2添加内容并写回staticVolatileTestString
  6. 线程3添加内容并写回staticVolatileTestString

这样会导致你的行丢失。在这里使用volatile没有帮助,整个字符串连接过程不是原子的。你需要在这些操作周围使用锁:

private static object sync = new object();

lock (sync) {
    staticVolatileTestString += Environment.NewLine + "\tthread " + ((int) o) + " setting threadInt to " + newVal;
}

2
MSDN描述了volatile关键字的作用,可以在此处查看

volatile关键字表示一个字段可能会被多个并发执行的线程修改。声明为volatile的字段不受编译器假定只有单个线程访问的优化影响。这确保了该字段中始终存在最新的值。

这意味着,在您的示例中,发生的情况大致如下(这可能会随时间而变化;取决于调度程序):
  • 线程0从staticVolatileTestString中读取字符串
  • 线程0追加'thread 0 setting threadInt to 88'
  • 线程0将字符串写回staticVolatileTestString
到目前为止都是预期的,但是:
  • 线程1-4从staticVolatileTestString中读取字符串
  • 线程1追加'thread 1 setting threadInt to 97'
  • 线程2追加'thread 2 setting threadInt to 11'
  • 线程2将字符串写回staticVolatileTestString
  • ...线程1、2、3正在读取、追加和写入
  • 线程4将字符串写回staticVolatileTestString
  • 等等...
看到这里发生了什么吗?线程4读取了字符串'thread 0 setting threadInt to 88',追加了它的'thread 4...'并将其写回,覆盖了线程1、2和3已经写入字符串中的所有内容。

那么你的意思是线程同时读取该值,然后作为单独的操作进行写入,这样就会互相干扰了吗?如果是这种情况,我需要在该字段上加锁(这就是我认为volatile的作用,但是我想它只是确保没有缓存对吧?)编辑:Lucero的答案证实了这一点。 - Xenoprimate
@Motig:在读取字符串或将其写回时,锁定存在,但在两者之间不存在。请记住:字符串是不可变的,因此会创建一个新的字符串实例,并将对该实例的引用线程安全地写回变量中。 - Philip Daubmeier
2
@Motig:volatile关键字在可变的基本数据类型(bool、int、double等)中运作得很好。然而,在字符串中,您需要设置专用锁,读取字符串,修改它并将其写回,然后释放锁。 - Philip Daubmeier
1
@Philip:小心。并非所有类型都保证原子读写。ECMA C#规范的12.5节指出:“其他类型(包括long、ulong、double和decimal,以及用户定义的类型)的读写不一定是原子性的。”如果您想要这些类型的原子性保证,那么您还需要一个锁,或者使用其中的一个Interlocked方法。 - LukeH
@LukeH:谢谢您提供的信息,很有用。我刚意识到一个问题,对于一个“volatile int”而言,“i = i + 1;”(读、修改、写)也存在同样的问题。你同样需要在这里使用锁。 - Philip Daubmeier
1
@Philip:没错,或者用 Interlocked.Add - LukeH

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