【问题标题】:Do function pointers return immediately in both C and C++?函数指针在 C 和 C++ 中都立即返回吗?
【发布时间】:2014-11-13 21:37:16
【问题描述】:

假设我想实现具有异步行为的函数,或者无论如何我只想使用函数指针,调用函数指针是否会导致调用关联函数,然后立即执行下一条指令?

例子

#include <iostream>
#include <cstdint>
int triple(int a) { return a * 3; }
void foo() { std::cout << "executing foo()" << '\n'; }
using fptrT = int (*)(int);
int main()
{
    fptrT p = triple;
    p(3);
    foo();
}

这两个标准对表达式 p(3) 被求值和 foo() 何时被执行的情况有何看法?

【问题讨论】:

  • 通过指针调用函数与直接调用函数没有区别。
  • @user2485710 C 和 C++ 都没有在核心语言中提供异步行为。您需要使用线程库或协程库。有很多可用的标准,并且较新的标准(C11 和 C++11)在标准库中支持线程。
  • @user2485710 间接 != 异步。函数指针很像类中的虚方法:相同的代码可以根据指针的值调用不同的函数。这与异步运行的函数完全正交。
  • 你为什么会期望异步行为?是什么让你这么认为?
  • @user2485710 第三方库只是在其中使用线程等,使其看起来像是对您的正常函数调用。

标签: c++ c pointers asynchronous function-pointers


【解决方案1】:

Christope's answer 是正确的。这是补充。

函数指针不能本身提供异步行为。标准居然bar这个。我对 C 标准比对 C++ 标准更熟悉,所以我会使用它。我的理解是在这一点上两者应该大致相同。

C11 标准对函数和函数指针的描述

让我们从 C 中函数调用的定义开始,在 6.5.2.2 第 3 段中给出:

后缀表达式后跟括号 () 包含可能为空的逗号分隔的表达式列表是函数调用。后缀表达式表示被调用的函数。表达式列表指定函数的参数。

并由第 1 段中的约束修改:

表示被调用函数 (92) 的表达式应具有指向返回 void 或返回数组类型以外的完整对象类型的函数的类型指针。

重要的是,随附的脚注 92 说:

大多数情况下,这是转换作为功能指示符的标识符的结果。

所以,C11 标准基本上将函数调用定义为调用函数指针的东西。并且,为此目的,命名函数标识符会自动转换为指向标识符中代码的函数指针。因此,C 认为函数和函数指针之间没有区别。

一个实验

虽然参考标准总是好的,但看看可靠的实现如何做事也非常有用。让我们做一个测试,我们编写相当简单的代码,然后查看底层程序集

代码:

#include <stdio.h>
#include <stdlib.h>

typedef void (*my_func_ptr)(int,int);

void my_function(int x, int y)
{
  printf("x = %d, y = %d, x + y = %d\n",x,y,x+y);
}

int main()
{
  /* declared volatile so the compiler has to call the function through
   * the pointer and cannot optimize it to call the function directly */
  volatile my_func_ptr fp = my_function; 

  my_function(3,5);
  fp(3,6);

  return 0;
}

我在 Mac OS X 上使用 gcc 编译代码,并使用默认优化 (gcc -o fptr fptr.c),这实际上是 LLVM 库的 gcc 前端。为了查看汇编,我在lldb 下运行程序,在main 处设置断点,并发出disassemble -f 命令,它反汇编当前函数。我使用settings set target.x86-disassembly-flavor intel 进行英特尔风格的组装。 lldb中默认的是AT&T风格,看起来有点不一样。

汇编中的main 例程是这样的:

  push   rbp
  mov    rbp, rsp
  sub    rsp, 0x20                   ; sets up the stack frame
  mov    edi, 0x3                    ; my_function(3,5). 1st arg: edi
  mov    esi, 0x5                    ;                   2nd arg: esi
  lea    rax, qword ptr [rip - 0x59] ; loads address of my_function into rax
  mov    dword ptr [rbp - 0x4], 0x0  
  mov    qword ptr [rbp - 0x10], rax ; saves address of my_function on stack
  call   0x100000ed0                 ; explicit call to my_function
  mov    eax, 0x0
  mov    edi, 0x3                    ; fp(3,6). 1st arg: edi
  mov    esi, 0x6                    ;          2nd arg: esi
  mov    rcx, qword ptr [rbp - 0x10] ; rcx <- address of my_function from stack
  mov    dword ptr [rbp - 0x14], eax
  call   rcx                         ; call address at rcx
  mov    eax, dword ptr [rbp - 0x14]
  add    rsp, 0x20
  pop    rbp
  ret    

请注意,这两个函数调用本质上是相同的。他们使用相同的程序集。两次实际调用都使用call op 调用。唯一的区别是地址第一次是硬编码的,而第二次地址存储在rcx寄存器中。另请注意,代码没有任何异步。

C11 关于序列点的说法

当您开始对序列点进行推理时,您实际上会发现,在单个线程中,标准不允许您期望的那种异步行为。在大多数情况下,C11 约束编译器执行由序列点按顺序分隔的代码。在第 5.1.2.3 节(程序执行)中,程序的执行顺序被定义为一系列序列点。相关定义基本上在第 3 段中:

Sequenced before 是由单个线程执行的评估之间的不对称、传递、成对关系,这会在这些评估之间产生偏序。给定任意两个评估 A 和 B,如果 A 在 B 之前排序,则 A 的执行将在 B 的执行之前。

在那段的后面:

在表达式 A 和 B 的求值之间存在一个序列点意味着与 A 相关的每个值计算和副作用都在与 B 相关的每个值计算和副作用之前排序。

基本上,这确立了由序列点分隔的代码必须同步(按顺序)执行。但是,如果编译器可以推断出两段代码不能相互影响,该标准提供了一个输出,在第 4 段中:

在抽象机中,所有表达式都按照语义的规定进行评估。如果一个实际的实现可以推断出它的值没有被使用并且没有产生所需的副作用(包括调用函数或访问易失性对象引起的任何副作用),则它不需要评估表达式的一部分。

那么,函数指针是如何进入这个的呢?附录 C 阐明了序列点位于表达式语句之间的含义,表达式语句本质上是以分号结尾的语句(参见 6.8.3)。这包括函数调用。

如何阻止函数指针的异步执行

考虑两个顺序函数调用:

f();
g();

都不接受参数,所以推理有点简单。调用必须按顺序执行,除非编译器可以推断f() 的任何副作用在g() 中未使用,反之亦然。编译器可以在函数中对此进行推理的唯一方法是编译器是否可以使用函数的代码。一般来说,这对于函数指针是不可能的,因为指针可以指向任何满足函数指针类型约束的函数。

请注意,在某些情况下,编译器可以推断出正确的函数(如果函数指针只分配一次并且存在于本地范围内),但通常情况并非如此。因此,编译器必须按照给出的顺序执行函数,并且第一个函数必须在第二个函数之前返回。

线程和协程库呢

C11 标准对线程有不同的规则。请注意,第 5.1.2.3 节将其自身限制为在单个线程中执行。使用堆栈的协程库本质上打破了 C11 机器模型,并且绑定到一组特定的实现(即:不需要移植到任何 C 环境)。协程库本质上必须提供自己的一组顺序保证。

【讨论】:

  • 我知道我错了,但你也有点“作弊”,因为你指的是实现细节,此外,你指的是特定机器上的单个实现。但我明白了,我只是以与实际不同的方式看待这一点。
  • 我没有作弊。其他人可以参考该标准,我现在并不特别关心,特别是因为您似乎更关心 C++ 而不是 C,而我尽量避免使用 C++ 标准。但是,如果必须的话,我会从 C 标准中添加一些关于序列点的内容。
  • 它们基本上具有相同的内存模型,它们也遵循相同的进化路径,例如 C11 也有线程,存在差异,但考虑到我的情况,我认为没有实质性差异在这里问。阅读一些具有某些上下文的 cmets 和数据总是很不错的,如果您认为这会增加讨论内容,那就去做吧。
  • @user2485710 好吧,这花了一点时间,有点笨拙,但也许仍然有用。
【解决方案2】:

函数指针只是调用函数的另一种方式,使用存储地址而不是固定的预定函数名:

C++11,第。 5.2.2.1:函数调用是后缀表达式 通过包含 (...) 函数参数的括号。为 普通函数调用,后缀表达式应为 引用函数的左值,或者它应该有指向函数类型的指针。

对于 C,措辞略有不同(C11,第 6.5.2.2 节),但原理是相同的(除了指向成员函数的指针,它在 C 中不存在)。

函数指针可用于回调机制,或实现设计模式,如strategy pattern,以动态定制通用算法。在 C++ 中,现在有更强大的替代方案,例如 lambda 函数或 function 对象。

如果你寻找异步函数调用,你应该看看std::async()

   std::future<int> ft = std::async (triple,3);   // invoque asynchronously
   //... remaining code 
   bool myresult = ft.get();  // get result when it is needed

请注意您的评论:很难预测性能,因为它取决于库实现、操作系统和硬件能力。但对我来说,它被证明是相当有效的:例如,在 MSVC2013 上,recent experiments 向我展示了为异步创建的线程在可能的情况下被重用,从而将创建开销降至最低。通过使用多核硬件异步确实可以提高整体吞吐量。

【讨论】:

  • 可怕,不是您的示例,而是标准实现,它基于线程,并且通过 async 返回的期货也很奇怪,它们有一个阻塞的 dtor。
  • 您希望async 还能如何工作?也许你应该澄清一下你的期望。析构函数阻塞是因为异步对象一旦不再需要就需要清理。任何 C++ 对象通常都是如此。
  • @RobertHarvey 是的,但是每次调用std::async 是1 个线程,如果我想在我的代码中调用std::async 1000 次,那么这一步就相当于产生1000 个线程!我喜欢 C++ 线程模型的问题还在于线程本身不能很好地扩展,它们只是繁琐,来自我期待任务模型的一种主要语言,而不是线程模型(有一些缺陷)。
  • 协程不一定需要额外的线程。您可以通过简单地继续执行剩余代码并稍后返回以获取原始函数的结果来推迟从函数中获取结果。您可以在一个线程(或两个线程)上完成所有这些操作;这就是 Node.JS 所做的,而这主要是 C# 中的 async 关键字所做的。尽管如此,这一切都与向函数调用添加额外的间接层无关。它们是两个不同的概念。
  • 换句话说,函数指针的目的是让您能够将函数更改为不同的函数,仅此而已。
猜你喜欢
  • 1970-01-01
  • 2015-08-05
  • 2012-02-26
  • 2010-12-25
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-01-12
  • 1970-01-01
相关资源
最近更新 更多