未初始化的局部变量是最快的随机数生成器

Is uninitialized local variable the fastest random number generator?

本文关键字:随机数生成器 局部变量 初始化      更新时间:2023-10-16

我知道未初始化的局部变量是未定义的行为(UB),并且该值可能具有可能影响进一步操作的陷阱表示,但有时我只想将随机数用于视觉表示,而不会在程序的其他部分进一步使用它们,例如,在视觉效果中设置具有随机颜色的东西,例如:

void updateEffect(){
    for(int i=0;i<1000;i++){
        int r;
        int g;
        int b;
        star[i].setColor(r%255,g%255,b%255);
        bool isVisible;
        star[i].setVisible(isVisible);
    }
}

快吗?
void updateEffect(){
    for(int i=0;i<1000;i++){
        star[i].setColor(rand()%255,rand()%255,rand()%255);
        star[i].setVisible(rand()%2==0?true:false);
    }
}

,也比其他随机数生成器更快?

正如其他人注意到的,这是未定义行为(UB)。

在实践中,它将(可能)实际上(有点)工作。在x86[-64]架构上从未初始化的寄存器中读取确实会产生垃圾结果,并且可能不会做任何坏事(与之相反的是,例如Itanium,寄存器可以被标记为无效,因此读取会传播像NaN这样的错误)。

有两个主要问题:

  1. 它不会特别随机。在这种情况下,你从堆栈中读取,所以你会得到之前在那里的东西。这可能是随机的,完全结构化的,你十分钟前输入的密码,或者你祖母的饼干食谱。

  2. 这是不好的(大写'B')练习让这样的东西潜入你的代码。从技术上讲,编译器可以在每次读取未定义变量时插入reformat_hdd();。它不会,但你不应该这样做。不要做不安全的事情。异常越少,您就越安全,避免意外错误所有时间

UB更紧迫的问题是,它使您的整个程序的行为未定义。现代编译器可以使用它来省略大量的代码,甚至回溯到过去。玩弄UB就像维多利亚时代的工程师拆除一个活的核反应堆。有无数的事情可能出错,你可能不知道一半的基本原理或实现的技术。它可能是可以的,但你仍然不应该让它发生。

还有,我会炒了你。

让我说清楚:我们在程序中不调用未定义行为。这永远都不是一个好主意。这条规则很少有例外;例如,如果您是一个库实现者,将实现offset。如果您的案例属于这种例外情况,您可能已经知道了这一点。在这种情况下,我们知道使用未初始化的自动变量是未定义的行为。

编译器在针对未定义行为的优化方面变得非常积极,我们可以发现许多未定义行为导致安全漏洞的情况。最臭名昭著的案例可能是Linux内核空指针检查删除,我提到在我的回答c++编译错误?其中编译器对未定义行为的优化将有限循环变成了无限循环。

我们可以阅读CERT的危险优化和因果关系的丧失(视频),其中包括:

越来越多的编译器编写者正在利用undefined在C和c++编程语言中的行为改进优化。

通常,这些优化会干扰开发人员对其程序执行因果分析的能力源代码,即分析下游结果的依赖性

因此,这些优化正在消除软件中的因果关系正在增加软件的概率错误、缺陷和漏洞。

特别是关于不确定的值,C标准缺陷报告451:未初始化自动变量的不稳定性使得一些有趣的阅读。它还没有得到解决,但引入了不确定值的概念,这意味着一个值的不确定性可以通过程序传播,并且可以在程序的不同点具有不同的不确定值。

我不知道发生这种情况的任何例子,但目前我们不能排除这种可能性。

真实的例子,不是你期望的结果

不太可能得到随机值。编译器可以对整个循环进行优化。例如,在这个简化的情况下:

void updateEffect(int  arr[20]){
    for(int i=0;i<20;i++){
        int r ;    
        arr[i] = r ;
    }
}

clang优化它(查看它的实时):

updateEffect(int*):                     # @updateEffect(int*)
    retq

或者可能得到全零,就像这个修改后的情况:

void updateEffect(int  arr[20]){
    for(int i=0;i<20;i++){
        int r ;    
        arr[i] = r%255 ;
    }
}

现场观看:

updateEffect(int*):                     # @updateEffect(int*)
    xorps   %xmm0, %xmm0
    movups  %xmm0, 64(%rdi)
    movups  %xmm0, 48(%rdi)
    movups  %xmm0, 32(%rdi)
    movups  %xmm0, 16(%rdi)
    movups  %xmm0, (%rdi)
    retq

这两种情况都是完全可以接受的未定义行为。

注意,如果我们使用的是Itanium,我们最终可能会得到一个陷阱值:

[…如果寄存器碰巧保存了一个特殊的非东西值,除少数指令外,读取寄存器陷阱[…]

其他重要事项

值得注意的是,在UB金丝雀项目中,gcc和clang在利用未初始化内存的未定义行为的意愿上存在差异。文章注释(强调我的):

当然,我们需要完全清楚,任何这样的期望与语言标准无关,而是与特定编译器碰巧做的事情有关,要么是因为编译器的提供者不愿意利用UB ,要么只是因为他们还没有找到机会利用它。当编译器提供程序没有真正的保证时,我们喜欢说尚未被利用的UBs是定时炸弹:当编译器变得更有攻击性时,它们正在等待下个月或明年爆炸。

正如Matthieu M.指出的"每个C程序员应该知道的关于未定义行为#2/3"也与这个问题有关。它说(强调mine):

要意识到的重要而可怕的事情是几乎任何基于未定义行为的优化可以开始被触发在将来的任何时候都有错误的代码。内联,循环展开,内存推广和其他优化将会越来越好它们存在的重要原因是暴露次要的像上面那样的优化

对我来说,这是非常不满意的,部分原因是编译器最终不可避免地受到指责,但也因为这意味着巨大

为了完整起见,我可能应该提到实现可以选择使未定义的行为定义良好,例如gcc允许通过联合进行类型双关,而在c++中这似乎是未定义的行为。如果是这种情况,实现应该将其记录下来,而这通常是不可移植的。

不,太糟糕了。

使用未初始化变量的行为在C和c++中都是未定义的,并且这种方案不太可能具有理想的统计特性。

如果你想要一个"快速而肮脏"的随机数生成器,那么rand()是你最好的选择。在它的实现中,它所做的只是一个乘法、加法和模数运算。

我所知道的最快的生成器要求您使用uint32_t作为伪随机变量I的类型,并使用

I = 1664525 * I + 1013904223

生成连续的值。您可以根据自己的喜好选择I的任何初始值(称为seed)。显然你可以内联编码。标准保证的无符号类型的封装充当模数。(数值常量是由杰出的科学程序员Donald Knuth精心挑选的)

好问题!

未定义并不意味着它是随机的。想想看,你在全局未初始化变量中得到的值是由系统或你/其他正在运行的应用程序留下的。根据您的系统如何处理不再使用的内存和/或系统和应用程序生成的值,您可能会得到:

  1. 总是一样的
  2. 是一个小的值集。
  3. 获取一个或多个小范围的值
  4. 从16/32/64位系统的指针中看到许多可被2/4/8整除的值

您将获得的值完全取决于系统和/或应用程序留下的非随机值。因此,确实会有一些噪音(除非您的系统清除不再使用的内存),但是您将从中抽取的值池将绝不是随机的。

局部变量的情况更糟,因为它们直接来自您自己程序的堆栈。你的程序很有可能在执行其他代码的过程中写出这些堆栈位置。我估计在这种情况下运气的机会非常低,而你所做的"随机"代码更改会尝试这种运气。

阅读随机性。正如你将看到的,随机性是一种非常具体且难以获得的属性。一个常见的错误是,如果你只是采取一些难以追踪的东西(比如你的建议),你就会得到一个随机值。

很多很好的答案,但请允许我再加一个,并强调在确定性计算机中,没有什么是随机的。对于伪rng生成的数字和在堆栈上为C/c++局部变量保留的内存区域中发现的看似"随机"的数字都是如此。

但是…

由一个好的伪随机生成器生成的数字具有使它们在统计上与真正的随机抽取相似的属性。例如,分布是均匀的。循环长度很长:在循环重复之前,你可以得到数百万个随机数。序列不是自相关的:例如,如果您取第2、3或27个数字,或者如果您查看生成的数字中的特定数字,您将不会开始看到奇怪的模式出现。

相反,留在堆栈上的"随机"数没有这些属性。它们的值和明显的随机性完全取决于程序的构造方式、编译方式以及编译器对程序的优化方式。举例来说,这是你的想法的一个变体,作为一个独立的程序:

#include <stdio.h>
notrandom()
{
        int r, g, b;
        printf("R=%d, G=%d, B=%d", r&255, g&255, b&255);
}
int main(int argc, char *argv[])
{
        int i;
        for (i = 0; i < 10; i++)
        {
                notrandom();
                printf("n");
        }
        return 0;
}

当我在Linux机器上用GCC编译这段代码并运行它时,结果是相当令人不快的确定性:

R=0, G=19, B=0
R=130, G=16, B=255
R=130, G=16, B=255
R=130, G=16, B=255
R=130, G=16, B=255
R=130, G=16, B=255
R=130, G=16, B=255
R=130, G=16, B=255
R=130, G=16, B=255
R=130, G=16, B=255

如果您用反汇编器查看编译后的代码,您可以详细地重建正在发生的事情。第一次调用notrandom()使用了之前程序没有使用的堆栈区域;谁知道里面有什么。但是在调用notrandom()之后,会调用printf() (GCC编译器实际上会将其优化为调用putchar(),但没关系)并覆盖堆栈。因此,在下一次和后续调用notrandom()时,堆栈将包含从putchar()执行中获得的陈旧数据,并且由于putchar()总是使用相同的参数调用,因此这些陈旧数据也将始终相同。

因此,这种行为绝对没有任何随机性,以这种方式获得的数字也不具有编写良好的伪随机数生成器的任何理想属性。事实上,在大多数现实生活场景中,它们的值将是重复的并且高度相关的。

事实上,和其他人一样,我也会认真考虑解雇那些试图把这个想法当作"高性能RNG"的人。

未定义的行为意味着编译器的作者可以自由地忽略这个问题,因为无论发生什么,程序员都没有权利抱怨。

理论上,当进入UB land 时,任何事情都可能发生(包括从你的nose上飞出去的守护进程),通常意味着编译器作者不会关心,对于局部变量,值将是堆栈内存中此时的值。

这也意味着内容通常是"奇怪的",但固定的或稍微随机的或可变的,但有一个明显的模式(例如,在每次迭代中增加值)。

当然你不能期望它是一个体面的随机生成器。

未定义行为未定义。这并不意味着你得到了一个未定义的值,它意味着程序可以做任何的事情,并且仍然满足语言规范。

一个好的优化编译器应该接受
void updateEffect(){
    for(int i=0;i<1000;i++){
        int r;
        int g;
        int b;
        star[i].setColor(r%255,g%255,b%255);
        bool isVisible;
        star[i].setVisible(isVisible);
    }
}

并将其编译为noop。这当然比其他任何选择都要快。它的缺点是它不会做任何事情,但这就是未定义行为的缺点。

还没有提到,但是调用未定义行为的代码路径可以做编译器想做的任何事情,例如

void updateEffect(){}

肯定比正确的循环快,并且由于UB,它是完全一致的。

由于安全原因,必须清理分配给程序的新内存,否则信息可能被使用,密码可能从一个应用程序泄漏到另一个应用程序。只有在重用内存时,才会得到不同于0的值。很有可能,在堆栈上,前一个值是固定的,因为之前对内存的使用是固定的。

您的特定代码示例可能不符合您的期望。虽然从技术上讲,循环的每次迭代都为r、g和b值重新创建局部变量,但实际上它们在堆栈上是完全相同的内存空间。因此,它不会在每次迭代中重新随机化,你最终将为1000种颜色中的每一种分配相同的3个值,而不管r, g和b分别是多么随机。

事实上,如果它确实有效,我会非常好奇是什么重新随机化了它。我能想到的唯一的办法就是在那个堆栈上加上一个交错的中断,这是极不可能的。也许将这些寄存器作为寄存器变量而不是真正的内存位置进行内部优化(寄存器在循环中被重用)也能达到目的,特别是当set可见性函数特别需要寄存器时。不过,这远不是随机的。

正如这里大多数人提到的未定义行为。未定义也意味着你可能会得到一些有效的整数值(幸运的是),在这种情况下,这会更快(因为没有进行rand函数调用)。但不要实际使用它。我敢肯定,这将是可怕的结果,因为运气不是一直与你在一起。

真的很糟糕!坏习惯,坏结果。考虑:

A_Function_that_use_a_lot_the_Stack();
updateEffect();

如果函数A_Function_that_use_a_lot_the_Stack()总是进行相同的初始化,那么它在堆栈中留下相同的数据。这个数据就是我们调用updateEffect(): 得到的,总是相同的值!

我做了一个非常简单的测试,它根本不是随机的。

#include <stdio.h>
int main() {
    int a;
    printf("%dn", a);
    return 0;
}

每次我运行程序,它都输出相同的数字(在我的例子中是32767)——你不能得到比这更少的随机。这大概是运行时库中的启动代码留在堆栈上的内容。由于每次程序运行时都使用相同的启动代码,并且在运行期间程序中没有任何其他变化,因此结果是完全一致的。

你需要有一个"随机"的定义。一个合理的定义是,你得到的值应该没有什么相关性。这是可以测量的。以一种可控的、可复制的方式实现这一目标也并非易事。因此,未定义的行为肯定不是你要找的。

在某些情况下,可以使用类型"unsigned char*"安全地读取未初始化的内存[例如,从malloc返回的缓冲区]。代码可以读取这样的内存,而不必担心编译器会抛出因果关系,有时候,让代码为内存可能包含的任何内容做好准备,可能比确保未初始化的数据不会被读取更有效(一个常见的例子是在部分初始化的缓冲区上使用memcpy,而不是离散地复制包含有意义数据的所有元素)。

然而,即使在这种情况下,人们也应该总是假设,如果任何字节的组合特别令人烦恼,那么读取它总是会产生这种字节模式(如果某种模式在生产中令人烦恼,但在开发中不是,那么这种模式直到代码在生产中才会出现)。

在嵌入式系统中,读取未初始化的内存作为随机生成策略的一部分可能是有用的,在嵌入式系统中,可以确定自上次系统上电以来内存从未被写入实质性的非随机内容,并且如果用于内存的制造过程导致其上电状态以半随机方式变化。即使所有设备总是产生相同的数据,代码也应该工作,但在一组节点每个都需要尽快选择任意唯一ID的情况下,拥有一个"不太随机"的生成器,为一半节点提供相同的初始ID可能比没有任何初始随机性更好。

正如其他人所说,它将是快速的,但不是随机的。

大多数编译器对局部变量所做的是在堆栈上为它们占用一些空间,但不会费心将其设置为任何东西(标准说它们不需要这样做,所以为什么要降低生成代码的速度呢?)

在这种情况下,你将获得的值将取决于之前在堆栈上的内容-如果你在此之前调用一个函数,该函数有100个局部char变量全部设置为'Q',然后在返回后调用你的函数,那么你可能会发现你的"随机"值表现得好像你已经将它们全部memset()为'Q'。

对于你的示例函数来说,重要的是,这些值不会在每次读取它们时改变,它们每次都是相同的。所以你会得到100颗星星都设置为相同的颜色和可见度。

同样,没有人说编译器不应该初始化这些值——所以将来的编译器可能会这样做。

总的来说:坏主意,不要做。(就像许多"聪明"的代码级优化一样…)

正如其他人已经提到的,这是未定义的行为(UB),但它可能"有效"。

除了其他人已经提到的问题,我看到了另一个问题(缺点)-它将不能在除C和c++以外的任何语言中工作。我知道这个问题是关于c++的,但如果你能写出好的c++和Java代码,这不是问题,那为什么不呢?也许有一天,有人将不得不将它移植到其他语言中,搜索由"魔术"引起的bug 这样的UB绝对是一场噩梦(特别是对于没有经验的C/c++开发人员)。

这里有一个关于另一个类似UB的问题。想象一下,你试图在不知道这个UB的情况下找到这样的bug。如果你想了解更多关于C/c++中这些奇怪的东西,请阅读链接中的问题答案,并查看这个GREAT幻灯片。它将帮助你了解它的底层是什么以及它是如何工作的;这不仅仅是另一个充满"魔力"的幻灯片。我确信即使是最有经验的C/c++程序员也能从中学到很多东西。

依赖于语言未定义行为的任何逻辑是一个好主意。除了在这篇文章中提到/讨论的内容之外,我想提到的是,使用现代c++方法/风格,这样的程序可能无法编译。

这在我之前的文章中提到过,其中包含了auto功能的优点和相同的有用链接。

https://stackoverflow.com/a/26170069/2724703

所以,如果我们改变上面的代码,用auto替换实际的类型,程序甚至不会编译。
void updateEffect(){
    for(int i=0;i<1000;i++){
        auto r;
        auto g;
        auto b;
        star[i].setColor(r%255,g%255,b%255);
        auto isVisible;
        star[i].setVisible(isVisible);
    }
}

我喜欢你的思维方式真的跳出了框框。然而,这种权衡是不值得的。内存-运行时权衡是一个东西,包括运行时的未定义行为是而不是

它一定给你一个非常不安的感觉,知道你正在使用这样的"随机"作为你的业务逻辑。我不会这么做的

在您想要使用未初始化变量的地方使用7757。我从一个素数列表中随机选择了它:

  1. 定义行为

  2. 保证不总是0

  3. 它是素数

  4. 它很可能和未初始化一样具有统计随机性变量

  5. 它可能比未初始化的变量更快,因为它

还有一种可能性可以考虑。

现代编译器(嗯,g++)非常聪明,它们会遍历你的代码,看看哪些指令会影响状态,哪些不会,如果一条指令保证不会影响状态,g++会直接删除那条指令。

结果是这样的。g++肯定会看到你正在读取,执行算术,保存,本质上是一个垃圾值,这会产生更多的垃圾。由于不能保证新的垃圾比旧的垃圾更有用,所以它将简单地取消循环。杂音!

这个方法很有用,但我要这样做。结合UB(未定义行为)和rand()速度。

当然,减少rand()的执行,但将它们混合在一起,这样编译器就不会做任何你不希望它做的事情。

我也不会解雇你。

如果操作得当,使用未初始化的随机数据不一定是一件坏事。事实上,OpenSSL正是这样做的,以播种其PRNG。

显然这种用法没有很好的记录,然而,因为有人注意到Valgrind抱怨使用未初始化的数据并"修复"它,导致PRNG中的错误。

所以你可以这样做,但你需要知道你在做什么,并确保任何人阅读你的代码理解这一点