【问题标题】:When Exactly Is A C#/.NET REF Parameter dereferenced? (Looking At Thread Safety)何时取消引用 C#/.NET REF 参数? (看线程安全)
【发布时间】:2019-07-18 10:22:18
【问题描述】:

这个问题具体是关于何时可以安全地调用 ref 的成员,该成员预计只能在监视器锁定下读取或写入。

在下面的示例中,仅希望在锁定时检查和设置类字段。私有实现只访问锁下的值,但是,它们实际上将成员作为ref,然后锁定锁并对给定的ref 执行工作。公共 Get 和 TrySet 方法实际上通过将请求的成员字段传递给 ref 来调用私有方法,并且它们不会在调用站点锁定锁 --- 问题是这实际上是安全的。

它应该是安全的,因为:虽然公共方法通过ref 引用成员字段而没有锁;在这个呼叫站点上,ref 将只是指针;并且直到在所需锁下的私有方法中才会取消引用实际的成员值​​。

以下情况不安全:

  1. 如果传递ref 的公共方法实际上读取了该值。如果是这样,那么私有方法将接收参数中的值,并作用于该值而不是当前字段值;这可能已被另一个线程更改(然后逻辑被破坏:然后该字段不会在与之比较的锁下被读取,并且具有现在陈旧的值)。
  2. 或者如果指针在公共调用站点和私有方法之间移动;但我很确定 CLR 确保不可能发生这样的事情。

请注意,我知道返回的对象 ITSELF 仍然不安全:我的问题仅在于 REF 内存的实际取消引用。

我已阅读 5.1.5 下的规范;并查看了生成的 IL 代码;我相信它是安全的。

这里是示例:公共方法线程安全吗?

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;


namespace Test
{
    public class TestRef
    {
        private readonly object syncLock = new object();
        private ulong eventCounter;
        private object objectValue;
        private long longValue = 1L;


        private T getValue<T>(ref T member)
        {
            lock (syncLock) {
                return member;
            }
        }

        private bool trySetValue<T>(ref T member, T value)
        {
            lock (syncLock) {
                if (object.Equals(member, value))
                    return false;
                member = value;
                ++eventCounter;
            }
            SomeValueChanged?.Invoke(this, EventArgs.Empty);
            return true;
        }


        public object GetObjectValue()
            => getValue(ref objectValue);

        public long GetLongValue()
            => getValue(ref longValue);

        public bool TrySetObjectValue(object value)
            => trySetValue(ref objectValue, value);

        public bool TrySetLongValue(long value)
            => trySetValue(ref longValue, value);


        public ulong EventCounter
        {
            get {
                lock (syncLock) {
                    return eventCounter;
                }
            }
        }

        public event EventHandler SomeValueChanged;
    }


    public static class Program
    {
        public static async Task<bool> ExampleTest(int taskCount)
        {
            TestRef testRef = new TestRef(); // longValue is 1L
            List<Task> tasks = new List<Task>(taskCount);
            for (int i = 0; i < taskCount; ++i) {
                tasks.Add(Task.Run(Callback)); // All Tasks will try set 2L
            }
            await Task.WhenAll(tasks);
            bool success = testRef.EventCounter == 1UL;
            Console.WriteLine(
                    $@"Ran {taskCount} Tasks: Raised event count: {testRef.EventCounter} (success: {success}).");
            return success;
            async Task Callback()
            {
                await Task.Delay(taskCount); // Cheaply try to pile Tasks on top of each other
                testRef.TrySetLongValue(2L);
            }

            // If not safe, then it is possible for MORE THAN ONE
            // Task to raise the event: i.e. two may
            // begin and the public method could read the
            // current value outside the lock, and both
            // would read 1L; and then BOTH would compare
            // the argument in the private method AS 1L
            // and both would then set the value and raise the event.
            // If safe, then only the first Task in would change
            // the value
        }

        public static void Main(string[] args)
        {
            int defaultTaskCount = Environment.ProcessorCount * 500;
            Console.WriteLine($@"Hello World.");
            Console.WriteLine(
                    $@"Specify how many parallel Tasks to run against {Environment.ProcessorCount} instances (each):");
            Console.WriteLine(
                    $@"--- The default will be {
                                defaultTaskCount
                            } Tasks against each instance [just type enter for the default]:");
            if (!int.TryParse(Console.ReadLine(), NumberStyles.Any, CultureInfo.CurrentCulture, out int taskCount))
                taskCount = defaultTaskCount;
            Console.WriteLine($@"Will Run {taskCount} Tasks against {Environment.ProcessorCount} instances (each) ...");
            List<Task<bool>> tasks = new List<Task<bool>>(Environment.ProcessorCount);
            for (int i = 0; i < Environment.ProcessorCount; ++i) {
                tasks.Add(Program.ExampleTest(taskCount));
            }
            Task.WhenAll(tasks)
                    .Wait();
            bool success = tasks.All(task => task.Result);
            Console.WriteLine($@"Success = {success}.");
            Console.WriteLine($@"Type a key to exit ...");
            Console.ReadKey();
        }
    }
}

public方法不加锁,通过引用传递成员;并且私有方法在读写之前锁定锁。

我假设它是安全的:传递的引用只是调用站点的指针;并且私有方法体实际上取消引用指针;那里的锁下面。

在生成的 IL 代码中,它看起来是安全的:只有指针被传递,并且直到在私有方法中的锁定下才会取消引用。

规范确实说“在函数成员或匿名函数中,引用参数被认为是最初分配的。” --- 但它说“考虑最初分配”......这可能会增加更多问题,但让我认为指针在使用之前不会受到尊重,因此上述内容总是安全的。

【问题讨论】:

  • 所有“考虑赋值”的意思是c#的规则要求refd变量在调用之前要赋值,这样被调用者才能知道它有值。它只是一个编译时错误检查器。
  • 如果你有一个Test对象的集合,枚举也不被认为是线程安全的。在枚举时,有人可能会更改集合。最好使用 ICollection.SyncRoot 或 Array.SyncRoot。
  • @EricLippert 我理解:我认为这意味着随时获取ref 指针总是安全的,并且您必须确保在锁定下取消引用它...所以我确实认为一切都很安全。我不能让测试程序失败...
  • 我认为你的问题的答案是 ref 在每次使用时都会被取消引用,而不仅仅是一次。
  • private T getValue&lt;T&gt;(ref T member) { lock (syncLock) { return member; } } 为什么我需要lock

标签: c# .net


【解决方案1】:

我编写了这个小示例程序来演示我对每次使用 ref 时都会取消引用的意思。我希望这会有所帮助。

就线程安全而言,当ref 被取消引用时几乎没有影响。您的代码需要确保没有两个线程同时修改该值,并且在写入线程完成之前没有线程正在读取该值。

您的新代码得到了很大改进。但请记住,使您的代码线程安全的是锁,而不是在取消引用 ref 时。

class Program
{
    static void Main(string[] args)
    {
        var a = new Test { Name = "First" };

        ref Test b = ref a;
        ref Test c = ref a;

        Console.WriteLine(b.Name); // dereferences b, prints "Hello World!"
        Console.WriteLine(c.Name); // dereferences c, prints "Hello World!"

        b = new Test { Name = "Goodbye :(" }; // change the target of ref b to a new object

        // dereference c again, points to the new object
        // prints "Goodbye :("
        Console.Write(c.Name); 
    }
}

public class Test
{
    public string Name { get; set; }
}

【讨论】:

    【解决方案2】:

    由于您是从类外部获取和设置值,因此根本无法确保线程安全。并且对于一个对象,引用正在更改而不是值,这意味着将值设置回您的 Test 类的每个线程都将更改引用。在此之前检索引用的线程将处理原始引用,而不是修改后的引用。

    通过下面的代码,您不仅可以看到数字乱序,而且还缺少 7 和 10,因为线程都处于竞争状态并且您的锁没有阻塞任何逻辑。

    var test = new Test();
    test.SetValue("");
    
    var newStrings = new string[] { "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten" };
    
    newStrings.AsParallel()
        .ForAll(newString =>
        {
            var value = test.GetValue() as string;
            value += $" {newString}";
            test.SetValue(value);
        });
    
    // Eight, Six, Three, Four, One, Two, Five, Nine
    var result = test.GetValue() as string;
    

    如果您想要线程安全,您需要在线程更改值时锁定该值。

    使用下面改进的代码,您会注意到现在您已经添加了所有单词,但它们仍然是乱序的。

    在处理线程安全时,您确实需要退后一步,审视全局,因为没有一个地方有几个锁会神奇地使整个类或应用程序线程安全。

    public class Test<T> where T : class
    {
        private readonly object syncLock = new object();
        private T _value;
    
        public delegate void MyAction(ref T value);
    
        public void EditObject(MyAction action)
        {
            lock (syncLock)
            {
                action(ref _value);
            }
        }
    
        public T GetValue()
        {
            lock (syncLock)
            {
                return _value;
            }
        }
    }
    
    var test = new Test<string>();
    
    var newStrings = new string[] { "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten" };
    
    newStrings.AsParallel()
        .ForAll(newString =>
        {
            test.EditObject((ref string o) => o += $" {newString}");
        });
    
    // Three Four Five Six One Two Ten Nine Eight Seven
    var result = test.GetValue() as string;
    
    

    【讨论】:

    • The threads that retrieved the object prior to this will be working on the original object, not the modified one. 这是正确的,但可能具有误导性。我将继续根据引用而不是对象说话。如果它读取The thread that retrieved the reference prior to this will be working on the original reference, not the modified one.,这将更加准确
    • @Jerry:我“加分”了你的答案,因为它是正确的;但我的问题没有完全回答。为了清楚起见,我编辑了我的问题...
    • @PatrickTucci 我同意并更新了我的答案。我可以一遍又一遍地重写我的答案,并且永远不会对措辞感到满意......
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-07-13
    • 2020-08-30
    • 2016-09-29
    • 2022-11-18
    • 1970-01-01
    • 2015-11-20
    • 1970-01-01
    相关资源
    最近更新 更多