【问题标题】:Thread safe high performance random generator线程安全的高性能随机发生器
【发布时间】:2020-11-20 22:44:54
【问题描述】:

我需要一个线程安全的高性能随机数生成器。我只需要值类型中的随机字节(现在是ulong),而不是在范围内。我使用了 C# 内置的 Random 类,但它有点慢而且不是线程安全的。

后来我转向了 XORShift 函数,它实际上工作得很好,但为了实现线程安全,我需要将计算放在 lock 中,这会大大降低性能。

我用来生成随机ulong 的内容如下:

public class Rand
{
    ulong seed = 0;
    object lockObj = new object();

    public Rand()
    {
        unchecked
        {
            seed = (ulong)DateTime.Now.Ticks;
        }
    }

    public Rand(ulong seed)
    {
        this.seed = seed;
    }

    public ulong GetULong()
    {
        unchecked
        {
            lock (lockObj)
            {
                ulong t = 0;

                t = seed;
                t ^= t >> 12;
                t ^= t << 25;
                t ^= t >> 27;
                seed = t;

                return t * 0x2545F4914F6CDD1D;
            }
        }
    }
}

这工作得很好而且很快,但是如果从 200 个并发线程中调用,锁定会使它花费大约 1-2us,否则计算在 100ns 下完成。

如果我删除锁定,则两个线程有​​可能采用相同的种子并计算相同的随机数,这对我的目的不利。如果我删除ulong t 声明并直接在种子上工作,那么为两个并发调用生成相同随机数的机会很小,但也有可能将值从值范围移出, 就像t &lt;&lt; 25 会被不同的线程连续多次调用而不进行旋转,它会变成简单的0。

我认为正确的方法是,如果有一个共享值可以被任何并发调用更改并在计算方法中使用该值,因为这些值是原子的(至少对于 CPU 内核而言)它不是如果许多计算同时使用它,则会出现问题,但是如果该值从位范围移出,则会出现问题。

有什么好的办法可以解决这个问题吗?如有任何帮助,我将不胜感激。

编辑:好的,我忘了说我无法控制线程,因为异步任务正在调用这个函数,所以线程是从线程池中随机出现的,使用线程 ID 也是一个没有解决方案,因为有一个特定线程可能永远不会再次调用此方法,并且为该 ID 保留一个实例并不是一件好事。

【问题讨论】:

  • 不是,但 xorshift 算法本身与语言无关。我本可以发布一个伪代码或 C 代码,但这就是我正在使用的,它在技术上是有效的,并且易于阅读,这就是我复制它的原因。 - 但无论如何你是对的,我删除了 C 标签。
  • 为每个线程提供自己的 Rand 实例是显而易见的方法。如果可以,请避免使用 ThreadLocal。您只需要确保使用另一个(锁定的)实例正确初始化种子即可。
  • @HansPassant 我编辑了问题,请阅读最后的“编辑”。
  • 为什么不直接省略锁定和临时变量t?让线程同时洗牌seed。它可能比现在更加随机。
  • 好吧,如果你想那样做,你就不能避免使用 ThreadLocal。 “无控制”很少是编写高性能代码的好方法。

标签: c# multithreading random


【解决方案1】:

只需在每个线程上创建一个Rand 实例。线程安全,无锁定,因此非常高效。这可以使用ThreadStaticAttribute 来实现。

public static class Rand
{
    [ThreadStatic] private static Rand defaultRand;
    public static Rand Default => defaultRand ??= new Rand();
    // Add extra methods for seeding the static instance...
}

// Then in any thread:
var randomNumber = Rand.Default.GetULong();

【讨论】:

  • 虽然这个答案非常有用,但非常感谢,我稍后会使用这个信息,我不是控制线程,因为它们是异步任务,并且随机来自线程池,所以我无法控制线程 ID。我应该把这个放在问题中。
  • @beatcoder - 所以你想要一个带有某种锁定的快速 RNG?
  • 我正在研究这个解决方案并且我喜欢它,但不幸的是仍然没有完全解决我的问题。将实例分配给单个线程的非常优雅的方式,但我应该让每个随机计算影响下一个计算,线程之间也一样。如果我放弃临时的“ulong t”变量,最好还是直接使用种子,而不是使用旋转来避免移位,而不是移位。还在思考可能出现的问题。我进行性能测试并决定哪种解决方案效果更好。无论如何我都赞成这个答案,因为它在技术上回答了这个问题。
  • @beatcoder 然后为每个实例使用不同的种子 Interlocked.Increment(ref _globalSeed)private static ulong _globalSeed = (ulong) Environment.TickCount; 基于您的实例启动种子
  • @beatcoder 是的,这确实意味着为每个实例保留额外的 64 位浮动,并在实例化时增加全局种子。但是,它也确保了唯一性。您的解决方案可能会受到 cpu 缓存中陈旧值的影响,并且 2 个线程获得完全相同的值的机会要高得多
【解决方案2】:

您可以在没有锁定的情况下执行此操作,并且仍然是线程安全的。假设计算非常快(确实如此)并且围绕它执行的代码较慢,如果另一个线程在开始计算和完成计算之间更改它,则简单地重新计算可能会更快。您可以使用Interlocked.CompareExchange 旋转循环来做到这一点。唯一的困难是没有 ulong 版本,所以我们必须使用不安全的方法来获得等价物。

private static unsafe ulong InterlockedCompareExchange(ref ulong location,
     ulong value, ulong comparand)
{
    fixed (ulong* ptr = &location)
    {
         return (ulong)Interlocked.CompareExchange(ref *(long*)ptr, (long)value, (long)comparand);
    }
 }

public ulong GetULong()
{
    unchecked
    {
       ulong prev = seed;

       ulong t = prev;
       t ^= t >> 12;
       t ^= t << 25;
       t ^= t >> 27;

       while (InterlockedCompareExchange(ref seed, t, prev) != prev)
       {
            prev = seed;
            t = prev;
            t ^= t >> 12;
            t ^= t << 25;
            t ^= t >> 27;
       }

       return t * 0x2545F4914F6CDD1D;
    }
}

【讨论】:

  • 赞成。我已经将类似解决方案的性能与 OP 的简单基于锁的方法进行了比较,使用锁的速度实际上是原来的两倍。这假设所有线程除了生成随机数之外什么都不做。但是这种方案的优点是可以避免频繁的线程切换,所以在OP的使用场景中可能会有好处。
  • @TheodorZoulias 是的,我看到类似的情况:如果你只生成随机数,它会变慢,因为会有很多旋转,但只要你在循环内做任何其他事情,它就会变成比 lock() 快。
  • @IanMercer 谢谢你的想法,看起来不错,我会尽快测试它的性能和熵。
  • @IanMercer:我刚刚测试过,500 个并发线程总共产生 1 亿个随机数。性能非常好,熵也很完美。没有一次碰撞。我认为我将此标记为已接受的答案,因为它完全满足要求。 (再生不是问题,但使用锁定是)谢谢!
  • 小修改:我把第二个计算放在 do{}while() 而不是 while(){} 以避免代码冗余,像这样我可以完全省略第一个计算,没有完全影响性能。
【解决方案3】:

我们在关键部分执行的代码越少,它的运行速度就越快。 它在我的 CPU 上运行速度提高了 30-50%。 您还可以使用准备下一个集合的异步进程

public sealed class Rand
{
    private ulong seed = 0;
    private readonly object lockObj = new object();
    
    public Rand()
    {
        
        unchecked
        {
            seed = (ulong) DateTime.Now.Ticks;
        }

        _current = 500;
    }

    public Rand(ulong seed)
    {
        this.seed = seed;
    }

    
    private ulong[] _batch = new ulong[501];
    private int _current = -1;

    public ulong GetULong2()
    {
        unchecked
        {
            ulong t = 0;
            lock (lockObj)
            {
                t ^= seed >> 12;
                t ^= t << 25;
                t ^= t >> 27;
                seed = t;
            }
            return t * 0x2545F4914F6CDD1D;
        }
    }


    public ulong GetULong5()
    {
        unchecked
        {

            var t = seed;
            t *= (uint)Thread.CurrentThread.ManagedThreadId;
            t ^= t >> 12;
            t ^= t << 25;
            t ^= t >> 27;
            seed = t;
            return t * 0x2545F4914F6CDD1D;
        }
    }
    public ulong GetULong()
    {

        unchecked
        {
            do
            {
            var current = Interlocked.Increment(ref _current);
    
            if (current < 501)
                return _batch[current];

            lock (lockObj)
            {
                if (_current >= 500)
                {
                    ulong t = seed;
                    for (int i = 0; i < 501; i++)
                    {
                        t ^= t >> 12;
                        t ^= t << 25;
                        t ^= t >> 27;
                        var result = t * 0x2545F4914F6CDD1D;
                        _batch[i] = result;

                    }
                    seed = t;

                    _current = -1;
                }
            }
            }while(true);


        }
    }

}

【讨论】:

  • 此解决方案使用缓存,这是一个不错的方法。谢谢你的主意。但不是很适合我,每个调用应该在同一时间下运行。像这样每 500 次调用将花费 500 倍以上的时间来生成一个批处理,并且稍后需要优化,例如将批处理计数设置为并发调用的计数。这将在一个非常复杂的代码中发生,并且已经有很多批处理,如果可能的话我会避免更多。我也赞成这一点,因为它在技术上回答了这个问题。谢谢!
  • 接下来看看GetULong2方法性能会如何提升。乘法是一个很长的操作,如果你把这段代码从临界区中删除,它会减少执行时间
  • 你说得对,我现在就去看看。很好的发现!
  • 我更正了这个例子,但现在它会变慢:)
  • 是的,有冲突的可能,我们需要在不同的处理器上测试代码。你可以添加一个Interlocked.Exchange(),但这也不能保证Cores之间的同步没有问题
【解决方案4】:

好的,this solution by l33t 是解决跨线程问题的一种非常好和优雅的方法,this solution by Stanislav 也是通过预生成和缓存批处理来防止按需生成的合适方法。感谢大家的想法。

同时我将位移更改为旋转,这将避免将种子移出为 0,并且我可以省略锁定。显然它工作得很好,并且延迟非常小(200 个并发线程,大约 100-120ns/调用)

public class Rand
{
    ulong seed = 0;
    object lockObj = new object();

    public Rand()
    {
        unchecked
        {
            seed = (ulong)DateTime.Now.Ticks;
        }
    }

    public Rand(ulong seed)
    {
        this.seed = seed;
    }

    public ulong GetULong()
    {
        unchecked
        {
            seed ^= (seed >> 12) | (seed << (64 - 12));
            seed ^= (seed << 25) | (seed >> (64 - 25));
            seed ^= (seed >> 27) | (seed << (64 - 27));
            seed *= 0x2545F4914F6CDD1D;

            int s = Environment.CurrentManagedThreadId % 64;

            return (seed >> s) | (seed << (64 - s));
        }
    }

    // even faster
    public ulong GetULong2()
    {
        unchecked
        {
            seed ^= (seed >> 12) | (seed << (64 - 12));
            seed ^= (seed << 25) | (seed >> (64 - 25));
            seed ^= (seed >> 27) | (seed << (64 - 27));
            ulong r = seed * 0x2545F4914F6CDD1D;
            seed = r;

            int s = Environment.CurrentManagedThreadId % 64;

            return (seed >> s) | (seed << (64 - s));
        }
    }

    // better entropy
    public ulong GetULong3()
    {
        unchecked
        {                    
            int s = Environment.CurrentManagedThreadId % 12;

            seed ^= (seed >> (12 - s)) | (seed << (64 - (12 - s)));
            seed ^= (seed << (25 - s)) | (seed >> (64 - (25 - s)));
            seed ^= (seed >> (27 - s)) | (seed << (64 - (27 - s)));
            ulong r = seed * 0x2545F4914F6CDD1D;
            seed = r;

            s = Environment.CurrentManagedThreadId % 64;

            return (r >> s) | (r << (64 - s));
        }
    }
}

虽然此解决方案最符合我的要求,但我不会将其标记为答案,因为它不会产生与问题中相同的随机数,但仍会在所有线程中产生看似唯一的随机数,因此仍然可能是解决方案。

编辑:好的,经过一些试验,GetULong() 是最快的,但是从 1 亿次随机生成中,在 200 个并发线程上产生了超过 23000 个碰撞值。与GetULong2() 相同。为GetULong3() 方法增加了一点额外的熵,它在 200 个并发线程上从 1 亿代中只产生了大约 210 个碰撞值,在 500 个并发线程上从 1 亿代中只产生了大约 50 个碰撞值。

对我来说,这种熵不仅仅是足够的,因为它们必须是唯一的,所以在我的应用程序中,在每一代之后,它们都试图原子地添加到集合中,如果已经有一个具有相同密钥的集合,那么随机被再次调用。即使在一般的随机生成器上,1 亿个事件中 50-200 次重试也是可以接受的,所以这对我来说绰绰有余,尤其是因为它速度很快,并且只需一个随机生成器实例即可安全使用。

感谢大家的帮助,我希望这也可以帮助其他人。

【讨论】:

  • 如果两个线程同时在返回点,则为相同的值
  • 由于过时的值,从统计上看,这不会像你想象的那样做,并使整个问题无效
  • @Stanislav:你说得对,我想我通过修改解决了这个问题。仍然有一点机会产生相同的随机数,但很少,这是可以接受的。
  • @TheGeneral:是的,Stanislav 指出了一个真正的问题。我用当前的编辑修复了它。从统计上讲,仍然有一点机会产生相同的随机数,但机会很小,这是可以接受的。
  • 好吧,无论如何,应用程序在没有随机锁定的情况下运行得更快,这是事实,所以我想我现在可以解决这个问题了。我需要继续工作,如果我对这个问题有更多了解,我会在这里分享。感谢您的帮助和测试代码!
猜你喜欢
  • 2012-02-07
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-01-31
  • 1970-01-01
  • 2017-04-20
  • 2012-08-19
  • 1970-01-01
相关资源
最近更新 更多