C++ `setjmp`/`longjmp`:非局部跳转与协程的底层构建

好的,各位观众,欢迎来到今天的“祖传秘籍”C++讲座!今天我们要聊的,是C++里一对古老而神秘的搭档:setjmplongjmp。这对活宝,虽然在现代C++里不那么常见了,但它们可是协程,甚至是异常处理的底层基石之一。别害怕,咱们用最通俗易懂的方式,把它们扒个底朝天!

第一部分:setjmp/longjmp是什么鬼?

简单来说,setjmp负责“设置一个跳跃点”,而longjmp负责“跳到那个点”。这就像你在玩游戏的时候,setjmp是存档,longjmp是读档。只不过,这里的存档和读档,针对的是程序的执行状态。

  • setjmp(jmp_buf env)

    • jmp_buf env:这是一个类型为jmp_buf的变量,它是一个数组,用来存储当前程序执行的上下文信息,包括栈指针、程序计数器等等。你可以把它想象成一个“时光胶囊”,能记住你当前的状态。
    • 返回值:第一次调用setjmp时,它会返回0。后续通过longjmp跳转回来时,它会返回longjmp的第二个参数(非零)。
  • longjmp(jmp_buf env, int val)

    • jmp_buf env:这是之前用setjmp保存的“时光胶囊”。
    • int val:这是一个整数值,会作为setjmp的返回值。注意,如果val为0,setjmp会返回1。
    • 作用:它会恢复env中保存的程序上下文,让程序从setjmp调用的地方继续执行,就像什么都没发生过一样。

代码示例:最简单的跳跃

#include <iostream>
#include <csetjmp> // 注意是csetjmp,不是setjmp

jmp_buf jump_buffer;

void my_function() {
  std::cout << "my_function() called" << std::endl;
  longjmp(jump_buffer, 42); // 跳回setjmp,并返回42
  std::cout << "This line will not be printed" << std::endl; // 这行不会执行
}

int main() {
  int return_value = setjmp(jump_buffer);

  if (return_value == 0) {
    std::cout << "setjmp() returned 0" << std::endl;
    my_function(); // 调用函数,函数会跳回这里
    std::cout << "This line will not be printed either" << std::endl; // 这行也不会执行
  } else {
    std::cout << "setjmp() returned " << return_value << std::endl; // 输出42
  }

  return 0;
}

这段代码的执行流程是这样的:

  1. main()函数调用setjmp(jump_buffer)setjmp返回0,并将当前程序状态保存到jump_buffer中。
  2. 程序继续执行,输出"setjmp() returned 0"。
  3. 调用my_function()
  4. my_function()中,调用longjmp(jump_buffer, 42),程序“嗖”的一下跳回到setjmp调用的地方。
  5. 这次,setjmp返回42(longjmp的第二个参数)。
  6. 程序进入else分支,输出"setjmp() returned 42"。

第二部分:注意事项和坑

setjmp/longjmp虽然强大,但使用时有很多坑,一不小心就会掉进去:

  1. 变量的值: longjmp会恢复栈指针,但不会恢复所有变量的值。在setjmp调用之后,如果变量的值发生了变化,那么longjmp之后,这个变量的值是不确定的(通常是setjmp之前的)。

    • 全局变量和静态变量: 这两种变量的值会在longjmp之后保持不变。
    • 自动变量(局部变量):
      • 如果自动变量在setjmp之前没有被赋值,那么longjmp之后,它的值是不确定的。
      • 如果自动变量被声明为volatile,那么longjmp之后,它的值会是longjmp调用时的值。
      • 否则,行为是未定义的。
    #include <iostream>
    #include <csetjmp>
    
    jmp_buf jump_buffer;
    int global_var = 10;
    
    int main() {
      int auto_var = 20;
      volatile int volatile_var = 30;
    
      int return_value = setjmp(jump_buffer);
    
      if (return_value == 0) {
        global_var = 100;
        auto_var = 200;
        volatile_var = 300;
        std::cout << "Before longjmp:" << std::endl;
        std::cout << "global_var: " << global_var << std::endl;
        std::cout << "auto_var: " << auto_var << std::endl;
        std::cout << "volatile_var: " << volatile_var << std::endl;
        longjmp(jump_buffer, 1);
      } else {
        std::cout << "After longjmp:" << std::endl;
        std::cout << "global_var: " << global_var << std::endl;    // Output: 100
        std::cout << "auto_var: " << auto_var << std::endl;      // Output: 20 (or undefined)
        std::cout << "volatile_var: " << volatile_var << std::endl; // Output: 300
      }
    
      return 0;
    }
  2. 资源泄漏: 如果在setjmplongjmp之间,你分配了内存、打开了文件、获取了锁等资源,longjmp不会自动释放这些资源,会导致资源泄漏。所以,在使用setjmp/longjmp的时候,一定要小心管理资源。

  3. 析构函数: longjmp不会调用栈上对象的析构函数。这会导致严重的问题,特别是涉及到RAII的时候。

    #include <iostream>
    #include <csetjmp>
    
    jmp_buf jump_buffer;
    
    class Resource {
    public:
      Resource() { std::cout << "Resource acquired" << std::endl; }
      ~Resource() { std::cout << "Resource released" << std::endl; }
    };
    
    int main() {
      int return_value = setjmp(jump_buffer);
    
      if (return_value == 0) {
        Resource r; // 创建Resource对象
        std::cout << "Before longjmp" << std::endl;
        longjmp(jump_buffer, 1);
      } else {
        std::cout << "After longjmp" << std::endl;
      }
    
      return 0;
    }

    在这个例子中,longjmp跳过了Resource对象的析构函数,导致资源没有被释放。

  4. 信号处理: setjmp/longjmp与信号处理函数之间的交互非常复杂,容易出错。尽量避免在信号处理函数中使用longjmp

  5. 可移植性: 不同的编译器和平台对setjmp/longjmp的实现可能有所不同,导致代码的可移植性问题。

  6. 不要跨线程 longjmp: setjmplongjmp不能跨线程使用,否则会导致未定义行为。

第三部分:setjmp/longjmp的应用:协程的雏形

虽然setjmp/longjmp有很多缺点,但在某些情况下,它们仍然很有用。其中一个重要的应用就是构建协程的底层机制。

协程,又称用户级线程或轻量级线程,是一种比线程更轻量级的并发执行模型。它可以在单线程中实现多任务并发执行,而不需要操作系统内核的参与。

setjmp/longjmp可以用来保存和恢复协程的执行上下文,从而实现协程的切换。

代码示例:一个简易的协程实现

#include <iostream>
#include <csetjmp>
#include <functional>

class Coroutine {
public:
  Coroutine(std::function<void()> func) : func_(func) {}

  void start() {
    if (setjmp(env_) == 0) {
      func_();
    }
  }

  void yield() {
    if (setjmp(yield_env_) == 0) {
      longjmp(env_, 1);
    }
  }

  void resume() {
    longjmp(yield_env_, 1);
  }

private:
  std::function<void()> func_;
  jmp_buf env_;
  jmp_buf yield_env_;
};

int main() {
  Coroutine co([]() {
    std::cout << "Coroutine started" << std::endl;
    Coroutine::yield();
    std::cout << "Coroutine resumed" << std::endl;
  });

  co.start();
  std::cout << "Back in main" << std::endl;
  co.resume();
  std::cout << "Main finished" << std::endl;

  return 0;
}

在这个例子中,我们定义了一个Coroutine类,它使用setjmp/longjmp来实现协程的切换。

  • start()方法:调用setjmp保存当前上下文,然后执行协程函数。
  • yield()方法:调用setjmp保存当前上下文(协程的上下文),然后longjmp回到start()方法,让主线程继续执行。
  • resume()方法:longjmp回到yield()方法保存的上下文,让协程从上次yield的地方继续执行。

当然,这只是一个非常简易的协程实现,它有很多缺点,比如无法处理堆栈溢出、无法传递参数等等。但是,它展示了setjmp/longjmp在协程中的基本应用。

第四部分:setjmp/longjmp vs 异常处理

你可能会问,既然C++已经有了异常处理机制(try/catch/throw),为什么还要用setjmp/longjmp呢?

其实,异常处理在底层实现上,也可能用到setjmp/longjmp(或者类似的机制)。但它们之间有几个重要的区别:

特性 setjmp/longjmp 异常处理 (try/catch/throw)
目的 非局部跳转,用于实现控制流的转移 处理程序中出现的异常情况,保证程序的健壮性
析构函数 不调用栈上的析构函数 调用栈上的析构函数(Stack Unwinding)
类型安全 不进行类型检查 进行类型检查,catch块只能捕获特定类型的异常
资源管理 需要手动管理资源,容易导致资源泄漏 RAII机制可以自动管理资源
使用场景 协程、状态机、错误处理(不推荐) 错误处理、保证程序的健壮性
可读性和维护性 难以理解和维护,容易出错 可读性和维护性较好

简单来说,异常处理更加安全、可靠、易于使用和维护。setjmp/longjmp则更加底层,更加灵活,但也更加危险。

第五部分:结论

setjmp/longjmp是C++里一对古老而强大的工具,它们可以实现非局部跳转,但使用时需要非常小心。虽然在现代C++里,我们通常会使用更安全、更高级的特性(比如异常处理、协程库),但了解setjmp/longjmp的原理,可以帮助我们更好地理解C++的底层机制。

就像学习武功一样,掌握了内功心法,才能更好地运用招式。setjmp/longjmp就是C++的内功心法之一。

今天的讲座就到这里,希望大家有所收获!下次有机会,我们再聊聊C++的更多“祖传秘籍”。谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注