函数效果分析

介绍

Clang 函数效果分析是一种语言扩展,可以警告“不安全”的构造。此功能目前专门针对性能约束属性 nonblockingnonallocating;具有这些属性的函数将被验证为不包含任何违反约束的语言构造或对其他函数的调用。(参见 Clang 中的属性。)

nonblockingnonallocating 属性

属性语法

nonblockingnonallocating 属性适用于函数类型,允许它们附加到函数、代码块、函数指针、lambda 和成员函数。

// Functions
void nonblockingFunction() [[clang::nonblocking]];
void nonallocatingFunction() [[clang::nonallocating]];

// Function pointers
void (*nonblockingFunctionPtr)() [[clang::nonblocking]];

// Typedefs, type aliases.
typedef void (*NBFunctionPtrTypedef)() [[clang::nonblocking]];
using NBFunctionPtrTypeAlias_gnu = __attribute__((nonblocking)) void (*)();
using NBFunctionPtrTypeAlias_std = void (*)() [[clang::nonblocking]];

// C++ methods
struct Struct {
  void NBMethod() [[clang::nonblocking]];
};

// C++ lambdas
auto nbLambda = []() [[clang::nonblocking]] {};

// Blocks
void (^nbBlock)() = ^() [[clang::nonblocking]] {};

该属性仅适用于函数本身。特别是,它不适用于任何嵌套函数或声明,例如代码块、lambda 和局部类。

本文档使用 C++/C23 语法 [[clang::nonblocking]],因为它与 noexcept 说明符的放置平行,并且这些属性与 noexcept 有其他相似之处。GNU __attribute__((nonblocking)) 语法也受支持。请注意,它在 C++ 类型别名上需要不同的放置。

noexcept 一样,nonblockingnonallocating 都有一个可选参数,一个编译时常量布尔表达式。默认情况下,该参数为 true,因此 [[clang::nonblocking]] 等效于 [[clang::nonblocking(true)]],并声明函数类型永远不会阻塞。

属性语义

noexcept 一起,nonallocatingnonblocking 属性定义了一系列有序的性能约束。从最弱到最强

  • noexcept(根据 C++ 标准):函数类型永远不会抛出异常。

  • nonallocating:函数类型永远不会在堆上分配内存或抛出异常。

  • nonblocking:函数类型永远不会在锁上阻塞,在堆上分配内存或抛出异常。

nonblocking 包括 nonallocating 保证。

虽然 nonblockingnonallocating 在概念上是 noexcept 的超集,但这两个属性都没有隐式指定 noexcept。此外,noexcept 具有指定的运行时行为,即如果抛出异常则中止,而 nonallocatingnonblocking 属性主要用于编译时分析,除了在使用 Clang 的 RealtimeSanitizer 构建的代码中之外,没有运行时行为。尽管如此,如果在 C++ 中,函数在没有 noexcept 的情况下声明为 nonblockingnonallocating,Clang 会发出警告。此诊断由 -Wperf-constraint-implies-noexcept 控制。

nonblocking(true)nonallocating(true) 适用于函数类型,并通过扩展适用于类似函数的声明。当应用于具有主体的声明时,编译器会验证函数,如以下“分析和警告”部分所述。

blockingallocating 分别是 nonblocking(false)nonallocating(false) 的同义词。它们可以用于类似函数的声明,以明确禁用在验证期间可能推断的任何 nonblockingnonallocating。(推断将在本文档的后面部分介绍)。nonblocking(false)nonallocating(false) 是合法的,但在应用于不是声明符一部分的函数类型时是多余的:float (int) [[nonblocking(false)]]float (int) 是相同的类型。

对于没有明确性能约束的函数,假定最坏的情况:该函数分配内存并可能阻塞,除非可以推断出其他情况。这将在验证讨论中详细介绍。

以下示例描述了这两个属性及其参数的所有排列的含义

void nb1_na1() [[clang::nonblocking(true)]] [[clang::nonallocating(true)]];
// Valid; nonallocating(true) is superfluous but doesn't contradict the guarantee.

void nb1_na0() [[clang::nonblocking(true)]] [[clang::nonallocating(false)]];
// error: 'allocating' and 'nonblocking' attributes are not compatible

void nb0_na1() [[clang::nonblocking(false)]] [[clang::nonallocating(true)]];
// Valid; the function does not allocate memory, but may lock for other reasons.

void nb0_na0() [[clang::nonblocking(false)]] [[clang::nonallocating(false)]];
// Valid.

类型转换

性能约束可以通过隐式转换来删除或削弱。尝试添加或加强性能约束是不安全的,会导致警告。这方面的规则与 C++17 及更高版本中 noexcept 的规则类似。

void unannotated();
void nonblocking() [[clang::nonblocking]];
void nonallocating() [[clang::nonallocating]];

void example()
{
  // It's fine to remove a performance constraint.
  void (*fp_plain)();
  fp_plain = unannotated;
  fp_plain = nonblocking;
  fp_plain = nonallocating;

  // Adding/spoofing nonblocking is unsafe.
  void (*fp_nonblocking)() [[clang::nonblocking]];
  fp_nonblocking = nullptr;
  fp_nonblocking = nonblocking;
  fp_nonblocking = unannotated;
  // ^ warning: attribute 'nonblocking' should not be added via type conversion
  fp_nonblocking = nonallocating;
  // ^ warning: attribute 'nonblocking' should not be added via type conversion

  // Adding/spoofing nonallocating is unsafe.
  void (*fp_nonallocating)() [[clang::nonallocating]];
  fp_nonallocating = nullptr;
  fp_nonallocating = nonallocating;
  fp_nonallocating = nonblocking; // no warning because nonblocking includes nonallocating
  fp_nonallocating = unannotated;
  // ^ warning: attribute 'nonallocating' should not be added via type conversion
}

虚方法

在 C++ 中,当虚方法具有性能约束时,子类中的覆盖方法会继承该约束。

struct Base {
  virtual void unsafe();
  virtual void safe() noexcept [[clang::nonblocking]];
};

struct Derived : public Base {
  void unsafe() [[clang::nonblocking]] override;
  // It's okay for an overridden method to be more constrained

  void safe() noexcept override;
  // This method is implicitly declared `nonblocking`, inherited from Base.
};

重新声明、重载和名称修饰

nonblockingnonallocating 属性与 noexcept 一样,不会影响依赖参数的查找和重载函数/方法。

首先,请考虑 noexcept 是函数类型不可分割的一部分

void f1(int);
void f1(int) noexcept;
// error: exception specification in declaration does not match previous
//   declaration

noexcept 不同,使用添加或加强的性能约束重新声明 f2 是合法的,并将属性传播到先前的声明

int f2();
int f2() [[clang::nonblocking]]; // redeclaration with stronger constraint is OK.

这通过使在不修改库头文件的情况下标注外部库中的函数成为可能,从而极大地简化了采用过程。

使用删除或削弱的性能约束重新声明会产生警告,与 noexcept 的行为类似

int f2() { return 42; }
// warning: attribute 'nonblocking' on function does not match previous declaration

在 C++14 中,以下两个 f3 的声明是相同的(单个函数)。在 C++17 中,它们是独立的重载

void f3(void (*)());
void f3(void (*)() noexcept);

类似地,以下两个 f4 的声明是独立的重载。这种模式可能会由于歧义而造成困难

void f4(void (*)());
void f4(void (*)() [[clang::nonblocking]]);

这些属性不会影响函数和方法名称的修饰。

Objective-C

这些属性当前不支持在 Objective-C 方法上使用。

分析和警告

约束

声明为 nonallocatingnonblocking 的函数在定义时会根据以下规则进行验证。这些函数

  1. 不得在堆上分配或释放内存。分析遵循由 newdelete 关键字生成的 operator newoperator delete 的调用,并将它们视为任何其他函数调用。全局 operator newoperator delete 没有声明为 nonblockingnonallocating,因此它们被视为不安全。(这是正确的,因为大多数内存分配器不是无锁的。请注意,operator new 的放置形式是在 libc++ 的 <new> 头文件中内联实现的,并且是可验证的 nonblocking,因为它只是将提供的指针转换为结果类型。)

  2. 不得抛出或捕获异常。要抛出异常,编译器必须在堆上分配该异常。(此外,std::exception 的许多子类都会分配字符串)。异常在捕获时被释放。

  3. 不得通过虚方法、函数指针或指向成员函数的指针进行任何间接函数调用,除非目标明确声明具有相同的 nonblockingnonallocating 属性(或更强)。

  4. 不得对任何其他函数进行直接调用,但以下情况除外

  1. 被调用者也明确声明具有相同的 nonblockingnonallocating 属性(或更强)。

  2. 如果被调用者与调用者定义在同一个翻译单元中,并且没有声明所需属性的 false 形式,根据相同的规则,可以验证被调用者具有相同或更强的属性。

  3. 被调用者是一个内置函数,已知不会阻塞或分配内存。

  4. 被调用者声明为 noreturn,并且如果编译 C++,被调用者还声明为 noexcept。此特殊情况将诸如 abort()std::terminate() 之类的函数从分析中排除。(在 C++ 中要求 noexcept 的原因是,声明为 noreturn 的函数可能是 throw 的包装器。)

  1. 可能不会调用或访问 Objective-C 方法或属性,因为 objc_msgSend() 会调用 Objective-C 运行时,这可能会分配内存或以其他方式阻塞。

  2. 可能不会访问线程局部变量。通常,线程局部变量在第一次访问时会在堆上分配。

声明为 nonblocking 的函数有一个额外的约束

  1. 可能不会声明静态局部变量(例如 Meyers 单例)。编译器会生成一个锁来保护变量的初始化。

违反任何这些规则都会导致警告,属于 -Wfunction-effects 类别。

void notInline();

void example() [[clang::nonblocking]]
{
  auto* x = new int;
  // warning: function with 'nonblocking' attribute must not allocate or deallocate
  //   memory

  if (x == nullptr) {
    static Logger* logger = createLogger();
    // warning: function with 'nonblocking' attribute must not have static local variables

    throw std::runtime_warning{ "null" };
    // warning: 'nonblocking" function 'example' must not throw exceptions
  }
  notInline();
  // warning: 'function with 'nonblocking' attribute must not call non-'nonblocking' function
  //   'notInline'
  // note (on notInline()): declaration cannot be inferred 'nonblocking' because it has no
  //   definition in this translation unit
}

推断 nonblockingnonallocating

在没有 nonblockingnonallocating 属性(无论 true 还是 false)的情况下,从性能受限函数调用的函数可能会被分析以推断它是否具有所需的属性。当函数不是虚方法并且在当前翻译单元中具有可见定义(即可以遍历其主体)时,就会进行此分析。

void notInline();
int implicitlySafe() { return 42; }
void implicitlyUnsafe() { notInline(); }

void example() [[clang::nonblocking]]
{
  int x = implicitlySafe(); // OK
  implicitlyUnsafe();
  // warning: function with 'nonblocking' attribute must not call non-'nonblocking' function
  //   'implicitlyUnsafe'
  // note (on implicitlyUnsafe): function cannot be inferred 'nonblocking' because it calls
  //   non-'nonblocking' function 'notInline'
  // note (on notInline()): declaration cannot be inferred 'nonblocking' because it has no
  //   definition in this translation unit
}

Lambda 和块

如前所述,性能约束属性仅适用于单个函数,而不适用于它内部嵌套的任何代码,包括块、lambda 和局部类。非阻塞函数可能会在另一个线程上调度阻塞 lambda 的执行。类似地,阻塞函数可能会创建用于实时上下文的 nonblocking lambda。

创建、销毁、复制和移动 lambda 和块的操作会根据底层函数调用进行分析。例如,创建带捕获的 lambda 会生成对匿名结构的构造函数的函数调用,并将捕获作为参数传递。

AST 中的隐式函数调用

nonblocking / nonallocating 分析在 Clang 的 Sema 分析阶段进行。在 Sema 期间,某些构造最终将成为函数调用,但在 AST 中不显示为函数调用。例如,auto* foo = new Foo; 将变成包含 CXXNewExpr 的声明,该声明被理解为对全局 operator new(在本例中)的函数调用,以及 CXXConstructExpr,该声明对于分析目的而言是 Foo 构造函数的函数调用。大多数分析中的差距是由于对最终成为函数调用的 AST 构造的知识不完整。

禁用诊断

函数效果诊断由 -Wfunction-effects 控制。

可以使用这样的构造来免除代码的检查。

#define NONBLOCKING_UNSAFE(...)                                    \
  _Pragma("clang diagnostic push")                                 \
  _Pragma("clang diagnostic ignored \"-Wunknown-warning-option\"") \
  _Pragma("clang diagnostic ignored \"-Wfunction-effects\"")       \
  __VA_ARGS__                                                      \
  _Pragma("clang diagnostic pop")

禁用诊断允许

  • 确实会阻塞但实际上以避免无限阻塞的方式使用的构造,例如使用信号量协调多个实时线程的线程池;

  • 使用安全但尚未注释的库;

  • 在大型代码库中逐步采用。

采用

在采用 nonblockingnonallocating 属性时,会出现一些常见问题。

C++ 异常

异常对性能约束的采用提出了挑战。引发异常的常见库函数包括

方法

替代方法

std::vector<T>::at()

operator[](size_t),在验证索引在范围内后。

std::optional<T>::value()

operator*,在检查 has_value()operator bool() 后。

std::expected<T, E>::value()

std::optional<T>::value() 相同。

std::function<R(Args...)>

std::function<R(Args...)> 通常与 nonblockingnonallocating 代码不兼容,因为典型的实现可能会在构造函数中分配堆内存。

替代方案

  • std::function_ref(在 C++26 中可用或作为 llvm::function_ref)。当仿函数的生命周期不需要超出创建它的函数时,这很合适也很理想。

  • 来自 WG14 的 inplace_function。这通过为仿函数包装器提供在编译时已知的固定大小并使用内联缓冲区来解决分配问题。

虽然这两种替代方法都解决了 std::function 的堆分配问题,但由于下一节中详细介绍的原因,它们仍然是 nonblocking/nonallocating 验证的障碍。

与类型擦除技术的交互

std::function<R(Args...)> 说明了常见的 C++ 类型擦除技术。使用模板参数推断,它将函数类型分解为其返回值和参数类型。函数类型的其他部分,包括 noexceptnonblockingnonallocating 以及任何其他属性,都会被丢弃。

标准库对函数类型的这些部分的支持不会很快到来。

代码可以通过以下两种方式解决此限制

  1. 避免像 std::function 这样的抽象,而是直接使用原始 lambda 类型。

  2. 创建专门的替代方案,例如 nonblocking_function_ref<R(Args...)>,其中实现及其接口中使用的所有函数指针都是 nonblocking

作为第一种方法的示例,当使用 lambda 作为 Callable 模板参数时,属性将被保留。

std::sort(vec.begin(), vec.end(),
  [](const Elem& a, const Elem& b) [[clang::nonblocking]] { return a.mem < b.mem; });

在这里,Compare 模板参数的类型是从 lambda 生成的匿名类,它具有一个包含 nonblocking 属性的 operator() 方法。

Callable 模板参数不是 lambda 或实现 operator() 的类,而是函数指针时,会出现一个复杂情况。

static bool compare_elems(const Elem& a, const Elem& b) [[clang::nonblocking]] {
  return a.mem < b.mem; };

std::sort(vec.begin(), vec.end(), compare_elems);

在这里,compare_elems 的类型分解为 bool(const Elem&, const Elem&),没有 nonblocking,在形成模板参数时。这可以使用第二种方法解决,创建一个专门的替代方案,该方案明确要求该属性。在这种情况下,可以使用一个小包装器将函数指针转换为仿函数。

template <typename>
class nonblocking_fp;

template <typename R, typename... Args>
class nonblocking_fp<R(Args...)> {
public:
  using impl_t = R (*)(Args...) [[clang::nonblocking]];

private:
  impl_t mImpl{ nullptr_t };
public:
  nonblocking_fp() = default;
  nonblocking_fp(impl_t f) : mImpl{ f } {}

  R operator()(Args... args) const
  {
    return mImpl(std::forward<Args>(args)...);
  }
};

// deduction guide (like std::function's)
template< class R, class... ArgTypes >
nonblocking_fp( R(*)(ArgTypes...) ) -> nonblocking_fp<R(ArgTypes...)>;

// --

// Wrap the function pointer in a functor which preserves ``nonblocking``.
std::sort(vec.begin(), vec.end(), nonblocking_fp{ compare_elems });

现在,compare_elemsnonblocking 属性在它被转换为 nonblocking 函数指针时被验证,作为 nonblocking_fp 构造函数的参数。模板参数是仿函数类 nonblocking_fp

静态局部变量

静态局部变量通常用于延迟构造的全局变量(Meyers 单例)。除了编译器使用锁来确保线程安全的初始化之外,在 nonblockingnonallocating 上下文中无意中触发初始化(涉及堆分配)非常容易。

通常,此类单例需要替换为全局变量,并且必须注意确保在从 nonblockingnonallocating 上下文中使用它们之前对其进行初始化。

注释库

分析不依赖于对任何原语的了解,这可能令人惊讶;它只是假设最坏的情况,即所有函数调用都是不安全的,除非明确标记为安全或能够推断为安全。对于 nonblocking,这似乎足以满足除最基本的自旋锁之外的所有情况。

至少对于操作系统的 C 函数,可以定义一个覆盖头文件,该头文件重新声明安全的常用函数(例如 pthread_self()),并添加 nonblocking。这可能有助于逐步采用此功能。

标准 C 库中的许多函数(特别是 <math.h>)也被 Clang 视为内置函数,诊断可以理解为安全。

C++ 标准库的很大一部分由内联模板函数组成,这些函数与推断配合良好。一些原语可能需要显式 nonblocking/nonallocating 属性。