普通视图

发现新文章,点击刷新页面。
昨天以前首页

std::thread()使用lambda表达式调用函数导致的离奇bug

2025年1月13日 10:44

最近项目升级开发环境,从visual stdio 2017升级到visual stdio 2022,出现奇怪的现象:同样的代码,2017编译出来风平浪静,2022编译出来一运行就是段错误。

我们的函数大概长这样:

class a {
    public:
    void DoThings(std::string str1, std::string str2, int idx){...};
    void Do0(){...};
    void OnInit() {
        std::string str1 = "FileName.txt";
        std::string str2 = "X:\\Dest\\Path\";
        int i = 0;
        std::thread thd = std::thread([&]{DoThings(str1, str2, i);});
        thd.detach();
    };
};

从debug表现来看,是调用线程函数的时候,传入了典型的野指针。但是啊,三个参数,两个是std::string,另外一个是int啊!string有问题可以理解,可int怎么还能错呢?
把参数改成传入前new,调用后delete,自然是解决了。但心里各种不爽,new一个int,脸往哪搁啊!
好在问题定位的范围比较小,只是起线程调用函数这一小块地方。

2017只支持到C++ 11,而2022是C++ 14,看来问题出在这里了。
去找lambda的说明:

For the entities that are captured by reference (with the capture-default [&] or when using the character &, e.g. [&a, &b, &c]), it is unspecified if additional data members are declared in the closure type, but any such additional members must satisfy.

人家说了,你用lambda进行引用捕获的时候,必须保证捕获的成员是安全的。
看到这里差不多明白了,是[&]的锅。[&]的意思是所有参数按照引用的方式捕获。而你的三个变量都是临时变量,传个毛线的引用啊!
如果不安全会怎么样?这玩意儿叫“未定义的行为”,爱咋样咋样。也就是说,我们的写法触发了这种未定义的右值引用行为,人家可以给你实装成保留地址,也可以转换成另外的指针进行实装。故而2017和2022都没错,错的是写代码的人。
继续写个例子验证一下:

#include "stdafx.h"
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

std::mutex g_mtx;

class CTester {
public:
    CTester() {
    };

    virtual ~CTester() {
    };
    void Run() {
        for (int i = 100, j = 1, n = 0; n < 3; i += 100, j += 1, n++) {
            std::thread thd = std::thread([&#038;] { Show(i, j, "std::thread([&#038;] { Show(i, j); }): "); });
            thd.detach();
            thd = std::thread([&#038;, i, j] { Show(i, j, "std::thread([&#038;, i, j] { Show(i, j); }): "); });
            thd.detach();
            thd = std::thread([&#038;, j] { Show(i, j, "std::thread([&#038;, j] { Show(i, j); }): "); });
            thd.detach();
            thd = std::thread([=] { Show(i, j, "std::thread([=] { Show(i, j); }): "); });
            thd.detach();
        }
    }
    void Show(int x, int y, const char* pri) {
        std::lock_guard< std::mutex>lock(g_mtx);
        char szOut[128] = { 0 };
        sprintf(szOut, "pri = %s x=%d y=%d\n", pri, x, y);
        std::cout << szOut;
    }
};

int main()
{
    std::cout << "Test Start" << '\n';
    CTester t;
    t.Run();
    _sleep(500);
    std::cout << "Test End" << '\n';
    int c = getchar();
    return 0;
}

2017的运行结果:
Test Start
pri = std::thread([&] { Show(i, j); }): x=100 y=1
pri = std::thread([&, i, j] { Show(i, j); }): x=100 y=1
pri = std::thread([&, j] { Show(i, j); }): x=100 y=1
pri = std::thread([=] { Show(i, j); }): x=100 y=1
pri = std::thread([&] { Show(i, j); }): x=200 y=2
pri = std::thread([&, i, j] { Show(i, j); }): x=200 y=2
pri = std::thread([&, j] { Show(i, j); }): x=200 y=2
pri = std::thread([=] { Show(i, j); }): x=200 y=2
pri = std::thread([&] { Show(i, j); }): x=300 y=3
pri = std::thread([&, i, j] { Show(i, j); }): x=300 y=3
pri = std::thread([&, j] { Show(i, j); }): x=300 y=3
pri = std::thread([=] { Show(i, j); }): x=300 y=3
Test End

2022的运行结果:
Test Start
pri = std::thread([&] { Show(i, j); }): x=7599872 y=1992189472
pri = std::thread([=] { Show(i, j); }): x=100 y=1
pri = std::thread([&] { Show(i, j); }): x=7599872 y=1992189472
pri = std::thread([&, i, j] { Show(i, j); }): x=100 y=1
pri = std::thread([&, j] { Show(i, j); }): x=7599872 y=1
pri = std::thread([&, i, j] { Show(i, j); }): x=200 y=2
pri = std::thread([&, j] { Show(i, j); }): x=7599872 y=2
pri = std::thread([=] { Show(i, j); }): x=200 y=2
pri = std::thread([&] { Show(i, j); }): x=7599872 y=1992189472
pri = std::thread([&, j] { Show(i, j); }): x=7599872 y=3
pri = std::thread([&, i, j] { Show(i, j); }): x=300 y=3
pri = std::thread([=] { Show(i, j); }): x=300 y=3
Test End

很明显,只要敢给2022(C++ 14)传引用,它就敢给你乱引……

这部分代码是从厂商的例子里抄的。人家写的是

std::thread([&]{Do0();});

只是隐式地捕获一个this!当然写[&]就行了。
友军抄的时候根本不知道方括号是干什么的,只改了后面,才造成了这样的后果。

那么怎么解决呢?写[=]或者[&, str1, str2, i]吗?并不是。
lambda叫啥?“匿名函数”啊!你都要调用真正的函数了,就别整匿名函数那一套了。
正确的std::thread调用类函数的时候应该长这样:

std::thread thrd = std::thread(&a::DoThings, this, str1, str2, i);
thrd.detach();

我是真心的不喜欢lambda。


  • (1):农历每年冬至所在的月固定为十一月。如果两个冬至间有13次朔望,则该年产生闰月。13个月中,第一个没有二十四节气里的偶数节气的月份就是闰月。因为十一月是起调点,所以农历十二月和正月是不会出现闰月的,正月初一和第一个节气春分受的影响也很小。我数学不好,但查表得到的结果,从1950年到2069的120年间,闰年就是双春,双春就是闰年,一一对应,无一例外。闰月是典型的用太阳历调整月历的规则,所以农历是阴阳历而不是纯阴(月)历。
  • (2):白名单上的微软网址是升级补丁用的
  • (3):统计表里记作4部是因为有同名,公式如何修改还没想好

《黑客与画家》中累加器引发的思考

2024年7月25日 16:00

本篇是关于《黑客与画家》中 Paul 用累加器作为例子营销 Lisp,第一次读他的书时,还看不太懂,多年后我大约能理解一点了。我想就此问题本身和此问题的外延讨论一番,当然,你也看到本文的成熟度为 3 / 5,说明随着我对技术的理解,后续会持续修订。

了解本篇探讨的内容时,需先了解一点背景。Paul Graham 因创办 Y Combinator (创业公司孵化器)而成为美国“创业教父”。在他的随笔集《黑客与画家》中举了一个例子,用 lisp 编写累加器来说明 lisp 语言能力。怎么理解这个累加器,其实每个人都见过,基本上可等于计算器上的那个 M+ 键在计算机软件中的实现。其次,Lisp 是一门如此久远而特别的编程语言,用 Paul Graham 《On Lisp》一书中的话来说,就是 lisp 是一种可编程的编程语言。

这里的可编程,也类似编程语言中的 Lambda(常以希腊字母 λ 表示)以及宏代表的元编程能力,Ruby 语言的作者特别描述了这块,Y Combinator Y 也许也有此意义。

累加器问题本身

Paul 提出一个类似编程问题:写一个函数,它能够生成累加器,即这个函数接受一个参数 n,然后返回另一个函数,后者接受参数 i,然后返回 n 增加了 i 后的值。就是说,写一个能生成函数的函数。

Common Lisp 的实现为:

(defun foo(n)
  (lambda (i) (incf n i)))

调用方式和结果可以是:

(setq acc (foo 2))
(print (funcall acc 6)) ; 8
(print (funcall acc 7)) ; 15

并且举出了 Ruby、Perl、Smarttalk、JavaScript 的实现,并列举了 Fortran、C、C++、Java、Visual Basic 似乎无法实现或优雅的实现。

特别的,Paul 强调了 Python 貌似比 Perl 优雅,但实现起来由于 lambda 支持的局限,也未能有类似实现。

从逻辑上,似乎是仅针对 Python 语言设计的一个偏好来对比它,未免显得不公。Python 的实现更函数式一点,也是表达了 Guido 对 Python 语言设计的取舍,弱化的 lambda。Lisp 其实算是函数式的鼻祖,但上面的累加函数反而不够满足,它对相同的输入,返回不同的值,因为它不够内聚,对外依赖初始值这个外部状态,不利于没有上下文时去理解程序,且 lambda 内部引用了 n 还修改了 n 的值,就好比你有一个钱包,当你第一次放了 10 块钱进去后,你发现它同时拿走了你上衣口袋的 8 块钱,第二次放了 10 块钱进去时,它又拿走了 7 块。(这个函数的实现上和学院教授的软件质量是对立的,这样的巧妙实现也许在内核级别或高度优化场景需要,但在应用软件上是完全不需要的。按现在软件质量看,它实现得非常野生,这样的能力只在能懂且能控制的人的设计中有用。对较大规模软件工程,就是灾难。)

如果某语言可以比较视觉优雅实现这个函数,则该语言函数须是一等公民(可以作为返回值),其次在闭包作用域实现上借鉴了 lisp。

让我来扩充现代语言的实现,比如 ES 的语法糖:

const foo = n => i => n + i;

Rust 显得格外严谨也显得强约束,其次整数类型 i32 还需要仔细斟酌:

fn foo(n: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |i| n + i)
}

其实,能优雅实现的不只 lisp 和 lisp 方言、ruby 等,连前辈 tcl 都优雅:

proc foo {n} {
    return [lambda {i} {expr {$n} + $i}]
}

唯独 Python 的实现要丑陋一点,原因是 lambda 匿名函数的不支持 return,要不,支持象牙符后的 Python,以下代码也确实合理:

def foo(n):
    return lambda i : return (n := n + i)

但现在,较恰当的实现如下,nonlocal 在内部作用域捕获外部 n,也是合理的,从代码清晰性上说,python 更甚一筹:

def foo(n):
    def bar(i):
        nonlocal n
        return (n := n + i)
    return bar

第二个实现,属于老油条代码,这个程序员至少理解引用:

def foo(n):
    s = [n]
    def bar(i):
        s[0] += i
        return s[0]
    return bar

但这种属于一种 hack 方式绕过语法的不支持,不深入研究一下 Python 特性无法一下看懂。

累加器问题外延

累加器问题外延其实是技术竞争优势,对强抽象表达的编程语言带来的软件质量,这属于高智商的智力游戏。

事出一个精通 lisp 的人选择使用 lisp 开发商业应用,并利用 lisp 语言优势快速开发了 Web 程序。

这一切都是开创性的,我非常理解这个过程,这意味着没有轮子,大部分需要自己造。

所有的编程都是开拓性的,而不是使用已有的成熟方案,lisp 正是这种 10X 程序员的秘密武器。

然后,这个创业过程还胜出了,这其中发挥作用的,我看到的,是真正的批判性思维,和基于事实逻辑和编程实力。

它提到的 lisp 的表达和抽象能力能帮助他以更少的代码击败那些 java 公司。

或许在他那个时代是这样的吧,lisp 是他的秘密武器,人才市场中,lisp 看似人少,但创业团队 10 人以下就够,且懂 lisp 的都是精英,你在 lisp 圈子中很容易找到这 10 个懂 lisp 的人。

我非常喜欢“拒绝平庸”、“书呆子的复仇”两章,也喜欢 lisp,但这个语言放在国内用于创业,基本是失败了一半。

一是找不到真正理解 lisp 的精英用它来配合现有基础设施开发商业应用,二是这个年代开发技术,特别是编程语言技术的差异,已经无法构成竞争优势。

但没关系,这不妨碍它成为书呆子的快乐。

c++ 一直在,perl 之后 java 崛起了,然后 ror 颠覆了 web 领域编程范式,然后是 go,也许 rust 之后,再没有像样的颠覆者了,dojo 可认为只是在某领域的优化,尤其是看到 ror 在 rust 中的实现 loco 时,似乎编程语言走向了终结。

总的感觉

即使 lisp 在语言能力上顶级的,但并不意味着 lisp 和商业成功之间有强关联性,而是使用 lisp 的背后的人的思维能力导致的。

用中国话说就是“艺高人胆大”,选择了少有人走到路,路还走通了,只不过这个艺,并不仅仅指 Paul 精通 lisp,而是指他习得的思维方式与精神。

思考方式和背后的精神;前者能给特立独行的创业者在混沌的商业环境之中获得优势分,后者确保思考方式。

至于技术能力和思考能力,那可以在事上练就。

❌
❌