为什么在同一类中创建的另一个线程可以访问局部变量?

15

我无法找到关于这个确切话题的任何信息,如果已经有相关问题,请引导我走向正确的方向。

根据我所了解的.NET,无法跨不同线程访问变量(如果我的说法不正确,请纠正我,这只是我在某处读到的)。

然而,在这个代码示例中,它似乎应该不能正常工作:

class MyClass
{
    public int variable;

    internal MyClass()
    {
        Thread thread = new Thread(new ThreadStart(DoSomething));
        thread.IsBackground = true;
        thread.Start();
    }

    public void DoSomething()
    {
        variable = 0;
        for (int i = 0; i < 10; i++)
            variable++;

        MessageBox.Show(variable.ToString());
    }
}

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void SomeMethod();
    {
        MyClass mc = new MyClass();
    }
}
当我运行SomeMethod()时,.NET是否应该抛出异常?因为创建的对象mc正在与在mc初始化器中创建的线程不同的线程中运行,并且这个新线程正在尝试访问mc的局部变量。 MessageBox 显示10,这并不是预期的结果,但我不确定为什么会起作用。
也许我不知道该搜索什么,但我找到的与线程相关的话题都没有解决这个问题,也许我的变量和线程的理解有误。
5个回答

54
根据我所了解的.NET,无法在不同线程之间访问变量。如果这种说法是错误的,请纠正我,因为这只是我在某处看到的。这种说法完全是错误的,所以请认为这是你的更正。你可能在某处看到过不能跨越不同线程访问本地变量的说法。这种说法也是错误的,但很常见。正确的说法是非async方法、迭代器块(即带有`yield return`或`yield break`的方法)中的本地变量以及匿名函数的外部变量可被多个线程访问。即使这种声明有点可疑;使用指针和`unsafe`代码块可以实现这一点,但尝试这样做是非常糟糕的想法。同时注意,您的问题询问本地变量,但给出了一个字段的示例。字段定义上不是本地变量。本地变量的定义是“仅在方法体内有意义”。在一般情况下:变量是指向内存的存储位置。线程是进程中的控制点,并且进程中的所有线程共享相同的内存;这就是它们是“线程”而不是“进程”的原因。因此,在一般情况下,除非采取某种机制来防止这种情况发生,否则所有变量都可以随时以任何顺序被多个线程访问。让我再说一遍,以确保这个概念在你的脑海中绝对清晰:单线程程序的正确思考方式是,除非有些事情使它们发生改变,否则所有变量都是稳定的。多线程程序的正确思考方式是,除非有些事情使它们停止或按顺序执行,否则所有变量都在不特定的顺序中不断变异。这就是为什么共享内存模型的多线程编程如此困难的根本原因,也是你应该避免使用它的原因。
在你的例子中,两个线程都可以访问this,因此两个线程都可以看到变量this.variable。你没有实现任何机制来防止这种情况,因此两个线程都可以以任何顺序读写该变量,几乎没有任何限制。一些你可以实现的机制来控制这种行为的方法包括:
- 将变量标记为ThreadStatic。这样做会在每个线程上创建一个新变量。 - 将变量标记为volatile。这样做会对读写的顺序施加某些限制,并且会对编译器或CPU进行的可能会导致意外结果的优化施加某些限制。 - 在每次使用变量时放置lock语句。 - 从一开始就不要共享变量。
除非你对多线程和处理器优化有深入的了解,否则我建议你使用后者以外的任何选项。
现在,假设您确实希望确保变量的访问失败。您可以让构造函数捕获创建线程的线程ID并将其存储起来。然后,您可以通过属性的getter/setter访问变量,在getter和setter中检查当前线程ID,并在它与原始线程ID不同的情况下引发异常。

这实际上是创建一个自己的单线程公寓线程模型。 "单线程公寓"对象是只能在创建它的线程上合法访问的对象。(你买了一台电视,放在你的公寓里,只允许公寓里的人看你的电视。) 单线程公寓与多线程公寓与自由线程的细节变得非常复杂; 更多背景信息请参见此问题。

你能解释一下STA和MTA吗?

这就是为什么你绝不能从工作线程访问在UI线程上创建的UI元素的原因; UI元素是STA对象。


@EricLippert:ThreadStatic(以及更新的ThreadLocal)真的需要“对多线程和处理器优化有深入的理解”吗?它们两个都让我感觉相对简单(尽管使用实例字段显然更简单)。我承认有一些陷阱(特别是在ThreadStatic方面),但没有什么过分的地方。 - Brian
@Brian:当然这很容易;你只需要每次获得一个新变量。但缺点当然是:你每次都会获得一个新变量!如果你的目标是共享内存,那么这与目标相悖。此外,对于逻辑上看起来相同的变量有两个不同的值会令人相当困惑。 - Eric Lippert
@EricLippert: “在一般情况下:变量是一个引用内存的存储位置。”这个概括准确吗?据我所知,编译器/即时编译器可以选择仅使用CPU寄存器来在硬件中表示变量。或者您是否认为寄存器是另一种类型的内存?感谢您出色的回答! - Eric J.
@EricJ.:你说得很好。一方面,寄存器没有地址,在页面文件中没有表示等等;从这个意义上说,它不是内存。然而,它是一个值的存储空间,因此在这个意义上是内存。对于这个答案,我认为我们关心两种内存:短期和长期。短期内存可以通过将变量寄存来实现,这是有趣的实现事实,但我认为它并没有太多关系。 - Eric Lippert
我同意它不会影响问题的答案。只是好奇那个陈述是否是简化了的。 - Eric J.
@EricLippert:你的建议是“一开始就不要共享变量。”这正是我想在我的任务中遵循的建议。我的问题是,我如何防止变量在我的任务之间共享?谢谢。 - Barry Dysert

8
据我所知,.NET不支持跨线程访问变量(如果我说错了,请指正,这只是我在某处读到的)。但事实并非如此。只要变量在范围内,它就可以从任何地方访问。
但请注意,在多个线程中访问同一变量时需要谨慎,因为每个线程可以在非确定性时间上对变量进行操作,这可能导致微妙且难以解决的错误。
有一个杰出的网站涵盖了.NET中的线程,从基础到高级的概念。 http://www.albahari.com/threading/

谢谢您的回答,看来在网上学习C#教程的时候需要非常小心。那么跨线程允许访问哪些变量呢?如果将该变量作为引用传递给另一个运行在不同类中的线程,则可以访问另一个线程的变量吗? - philkark
2
线程和变量作用域是不同的概念。你可以在类中有一个方法来启动5个线程,每个线程都可以访问类中的任何变量。 - Eric J.
谢谢提供链接,我会仔细阅读并希望能够正确地学习有关线程的知识。 - philkark
为了澄清先前的观点,Eric J:作用域纯粹是编译器的关注点。如果一个有名称的东西可以在没有限定词的情况下被使用来指代该事物,则称其为在作用域内。在原始示例中,variable在作用域内,因为编译器知道当这个token出现在声明该字段的类的主体内部时,这个token被允许引用该名称的字段而无需任何限定。 - Eric Lippert

5

我有些晚了,@Eric J.给出的答案很好,言简意赅。

我想为您的另一个问题增加一些澄清,那就是您对线程和变量的理解。

在您的问题标题中,您说“变量可以在另一个线程中被访问”。 除此之外,根据您的代码,您正在从恰好1个线程中访问您的变量,该线程在这里创建:

    Thread thread = new Thread(new ThreadStart(DoSomething));
    thread.IsBackground = true;
    thread.Start();

所有这些事情让我意识到你害怕一个与实际创建MyClass实例的线程不同的线程会使用实例内部的某些内容。
以下事实对于更清晰地了解多线程很重要(它比你想象的简单):
- 线程不拥有变量,它们拥有堆栈,堆栈可能包含一些变量,但这不是我的重点。 - 在一个类的实例被创建的线程和那个线程之间没有固有的连接。它被所有线程所拥有,就像它不被任何线程所拥有一样。 - 当我说这些话时,我不是在谈论线程堆栈,但可以说线程和实例是两组独立的对象,它们仅为了更好的效果而互相影响:)
编辑:
我看到“线程安全”的词出现在这个答案线程中。如果您想知道这些词的含义,我推荐@Eric Lippert的这篇伟大文章:http://blogs.msdn.com/b/ericlippert/archive/2009/10/19/what-is-this-thing-you-call-thread-safe.aspx

谢谢你的回答,你直接解决了我关于变量“所有权”的问题。线程安全性我已经有所了解,但似乎并没有回答我在这里提出的问题。 - philkark

2
内存位置不仅限于单个线程。如果是这样的话,那将非常不方便。在CLR中,内存只在应用程序域边界处隔离。这就是为什么每个静态变量都有一个独立的实例,每个应用程序域都有一个。然而,线程并没有绑定到任何特定的应用程序域。它们可以在多个应用程序域或没有(非托管代码)中执行代码。它们不能同时从多个应用程序域执行代码。这意味着线程不能同时访问来自两个不同应用程序域的数据结构。这就是为什么您必须使用编组技术(例如通过MarshalByRefObject)或使用通信协议,如.NET Remoting或WCF来访问另一个应用程序域中的数据结构。
考虑以下进程托管CLR的Unicode艺术图表。
┌Process───────────────────────────────┐
│                                      │
│ ┌AppDomain───┐        ┌AppDomain───┐ │
│ │            │        │            │ │ 
│ │       ┌──────Thread──────┐       │ │
│ │       │                  │       │ │
│ │       └──────────────────┘       │ │
│ │            │        │            │ │
│ └────────────┘        └────────────┘ │
└──────────────────────────────────────┘

您可以看到,每个进程可以拥有多个应用程序域,一个线程可以执行来自其中不止一个的代码。我还试图说明一个事实,即一个线程也可以执行非托管代码,通过在左右 AppDomain 块之外显示其存在。
因此,基本上一个线程对于在其当前执行的同一应用程序域中的任何数据结构都具有简单和非简单访问权限。我在这里使用“简单”这个术语来包括通过一个类到另一个类的公共、受保护或内部成员进行的内存访问(数据结构、变量等)。线程并不会阻止这种情况发生。然而,使用反射,您仍然可以访问另一个类的私有成员。这就是我所说的非简单访问。是的,这需要您付出更多的努力,但一旦完成了反射调用(顺便说一下,这必须经过代码访问安全性的允许,但这是另一个话题),否则没有什么花哨的东西。重点是,一个线程可以访问同一应用程序域中几乎所有的内存。
线程几乎可以访问同一应用程序域中的所有内容的原因是,如果不能这样做,那将是极其限制的。在多线程环境中工作时,开发人员必须花费大量的额外努力来共享数据结构。
总结一下要点:
  • 数据结构(类/结构体)或其组成成员与线程之间没有一对一的关系。
  • 线程与应用程序域之间也没有一对一的关系。
  • 实际上,甚至操作系统线程和CLR线程之间也没有一对一的关系(尽管在现实中,我知道没有主流的CLI实现偏离这种方法1)。
  • 显然,CLR线程仍然局限于创建它的进程。

即使是奇点操作系统似乎也直接将.NET线程映射到操作系统和硬件上。

2
不,你弄反了,只要数据仍在范围内,它就是可访问的。
你需要防止相反的问题,即两个线程同时访问同一数据,这被称为竞争条件。您可以使用诸如lock之类的同步技术来防止发生这种情况,但如果使用不当,它可能会导致死锁。
阅读C# Threading in .NET以获取教程。

线程同时访问数据会导致竞态条件,这是一个不确定的结果。您可以使用同步技术(如锁定)来解决这个问题,如果使用不当,可能会导致死锁。只需更加智能地使用锁定即可避免死锁。 - Servy

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