再探虚函数

虚函数是一种成员函数,其行为可以在派生类中被覆盖,支持动态调用派发。

使用示例代码如下:

extern "C" {
// 避免 operator<< 多次调用,简化汇编代码
void println(const char *s) { std::cout << s << std::endl; }
}

void *operator new(size_t n) {
    void *p = malloc(n);
    std::cout << "new " << n << " ptr " << p << std::endl;
    return p;
}

void operator delete(void *ptr) noexcept {
    std::cout << "delete " << ptr << std::endl;
    free(ptr);
}

struct Fd {
    Fd() : fd_(12345) {}
    virtual ~Fd() { println("Fd::~Fd"); }
    virtual void close() { println("Fd::close"); }
    void setsockopt() { println("Fd::setsockopt"); }

    int fd_;
};

struct Conn : public Fd {
    virtual ~Conn() { println("Conn::~Conn"); }
    virtual void connect() { println("Conn::connect"); }
};

struct TCPConn : public Conn {
    ~TCPConn() { println("TCPConn::~TCPConn"); }
    void connect() override { println("TCPConn::connect"); }
    void local_addr() {}
};

struct UDPConn : public Conn {
    ~UDPConn() { println("UDPConn::~UDPConn"); }
    void connect() override { println("UDPConn::connect"); }
    void local_addr() {}
};

typedef void (*Fn)(void *);

template <typename T>
void call_by_vtable(T *v, size_t idx) {
    uintptr_t *vtable_ptr = reinterpret_cast<uintptr_t *>(*reinterpret_cast<uintptr_t *>(v));
    // std::cout << "vtable address " << vtable_ptr << std::endl;

    void *vtable_entry_ptr = reinterpret_cast<void *>(*(vtable_ptr + idx));
    // std::cout << "  vtable entry " << idx << "th address " << vtable_entry_ptr << std::endl;

    Fn f = (Fn)vtable_entry_ptr;
    f(v);
}

int main() {
    Fd *conn = new TCPConn;
    conn->close();
    conn->setsockopt();
    delete conn;

    println("");

    conn = new UDPConn;
    call_by_vtable(conn, 1);
}

虚表

虚表是在编译期进行生成,在构造函数内设置对象的虚表地址,先于成员对象的初始化。

通过反汇编 TCPConn 相关代码,得到虚表的定义如下(比实际的要大一些,对象的虚表起始地址一般有两个偏移)

	.weak	_ZTV7TCPConn
	.section	.data.rel.ro.local._ZTV7TCPConn,"awG",@progbits,_ZTV7TCPConn,comdat
	.align 8
	.type	_ZTV7TCPConn, @object
	.size	_ZTV7TCPConn, 48
_ZTV7TCPConn:
	.quad	0
	.quad	_ZTI7TCPConn
	.quad	_ZN7TCPConnD1Ev
	.quad	_ZN7TCPConnD0Ev
	.quad	_ZN2Fd5closeEv
	.quad	_ZN7TCPConn7connectEv

虚表大小为 48 (_.size ZTV7TCPConn, 48),分别是

  • 0 填充
  • _ZTI7TCPConn 构造函数
  • _ZN7TCPConnD1Ev 普通的析构函数函数
  • _ZN7TCPConnD0Ev 调用 delete 的析构函数
  • _ZN2Fd5closeEv 成员函数 close,继承自父类
  • _ZN7TCPConn7connectEv 成员函数 connect

构造函数的汇编代码如下

	.section	.text._ZN7TCPConnC2Ev,"axG",@progbits,_ZN7TCPConnC5Ev,comdat
	.align 2
	.weak	_ZN7TCPConnC2Ev
	.type	_ZN7TCPConnC2Ev, @function
_ZN7TCPConnC2Ev:
.LFB1796:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$16, %rsp
	movq	%rdi, -8(%rbp)
	movq	-8(%rbp), %rax
	movq	%rax, %rdi
	call	_ZN4ConnC2Ev
	leaq	16+_ZTV7TCPConn(%rip), %rdx
	movq	-8(%rbp), %rax
	movq	%rdx, (%rax)
	nop
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE1796:
	.size	_ZN7TCPConnC2Ev, .-_ZN7TCPConnC2Ev
	.weak	_ZN7TCPConnC1Ev
	.set	_ZN7TCPConnC1Ev,_ZN7TCPConnC2Ev
  • 调用父类构造函数
  • 设置虚表指针,将虚表第三个条目的地址(_ZTV7TCPConn+16)也就是 _ZN7TCPConnD1Ev 的地址存在 this 指针的内存中 movq %rdx, (%rax)
leaq	16+_ZTV7TCPConn(%rip), %rdx
movq	-8(%rbp), %rax
movq	%rdx, (%rax)

虚函数调用

虚函数的调用需要查询虚函数表,找到对应的函数地址进行调用。

使用 conn->close() 来说明,其部分反汇编代码如下:

movq	-24(%rbp), %rax  ; this 指针
movq	(%rax), %rax     ; 解地址引用,取到对象虚表地址,地址为 _ZN7TCPConnD1Ev
addq	$16, %rax        ; 取对象虚表的第三个条目
movq	(%rax), %rdx     ; 解地址引用得到 _ZN2Fd5closeEv
movq	-24(%rbp), %rax
movq	%rax, %rdi       ; 将 this 指针作为第一个参数
call	*%rdx            ; 调用 Fd::close

非虚函数调用

普通函数调用 conn->setsockopt(); 无额外动作,其汇编代码如下

movq	-24(%rbp), %rax
movq	%rax, %rdi
call	_ZN2Fd10setsockoptEv

析构函数

TCPConn 反汇编看到实现为两个函数,

  • 普通的析构函数
  • 额外调用 delete 的析构函数

为保持可读性,删除了部分 cfi 等内容,一下为汇编代码

	.align 2
	.weak	_ZN7TCPConnD2Ev
	.type	_ZN7TCPConnD2Ev, @function
_ZN7TCPConnD2Ev:
.LFB1779:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$16, %rsp
	movq	%rdi, -8(%rbp)
	leaq	16+_ZTV7TCPConn(%rip), %rdx
	movq	-8(%rbp), %rax
	movq	%rdx, (%rax)
	leaq	.LC7(%rip), %rax
	movq	%rax, %rdi
	call	println
	movq	-8(%rbp), %rax
	movq	%rax, %rdi
	call	_ZN4ConnD2Ev
	nop
	leave
	ret

	.weak	_ZN7TCPConnD1Ev
	.set	_ZN7TCPConnD1Ev,_ZN7TCPConnD2Ev
	.type	_ZN7TCPConnD0Ev, @function
_ZN7TCPConnD0Ev:
.LFB1781:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$16, %rsp
	movq	%rdi, -8(%rbp)
	movq	-8(%rbp), %rax
	movq	%rax, %rdi
	call	_ZN7TCPConnD1Ev
	movq	-8(%rbp), %rax
	movl	$16, %esi
	movq	%rax, %rdi
	call	_ZdlPvm@PLT
	leave
	ret

析构函数的实现和虚成员函数没有区别,都是偏移查表然后调用。

movq	(%rax), %rdx
addq	$8, %rdx
movq	(%rdx), %rdx
movq	%rax, %rdi
call	*%rdx

注意这里偏移为 8,也就是将调用 _ZN7TCPConnD0Ev

观察 _ZN7TCPConnD0Ev 的实现,先调用普通的析构函数 _ZN7TCPConnD1Ev ,然后再调用 _ZdlPvm@PLT 也就是 operator delete

在析构函数的调用链中,不再进行查表的动作,也就是说整个 delete 过程只需要查一次虚表,两次解指针引用

  • 获取虚表地址
  • 获取虚函数地址

手动通过 this 指针来调用虚函数

针对以上代码,使用 C++ 代码来手动调用虚函数.

typedef void (*Fn)(void *);

template <typename T>
void call_by_vtable(T *v, size_t idx) {
    uintptr_t *vtable_ptr = reinterpret_cast<uintptr_t *>(*reinterpret_cast<uintptr_t *>(v));
    // std::cout << "vtable address " << vtable_ptr << std::endl;

    void *vtable_entry_ptr = reinterpret_cast<void *>(*(vtable_ptr + idx));
    // std::cout << "  vtable entry " << idx << "th address " << vtable_entry_ptr << std::endl;

    Fn f = (Fn)vtable_entry_ptr;
    f(v);
}

T *v 是对象地址,也就是 this 指针。两次解指针引用获取到虚表中第 idx 个虚函数地址。由于示例中所有代码都是无参函数,所以直接传入 this 指针即可进行调用。

测试手动执行虚析构函数

int main() {
    Fd *conn = new TCPConn;
    conn->close();
    conn->setsockopt();
    delete conn;

    println("");

    conn = new UDPConn;
    call_by_vtable(conn, 1);
}

可以看到 delete 和 手动寻址调用 的输出相同

new 16 ptr 0x560d34c13eb0
Fd::close
TCPConn::~TCPConn
Conn::~Conn
Fd::~Fd
delete 0x560d34c13eb0

new 16 ptr 0x560d34c13eb0
UDPConn::~UDPConn
Conn::~Conn
Fd::~Fd
delete 0x560d34c13eb0

虚函数性能

使用简单的一个用例来测试两个函数的性能

static void BM_call_normal_fn(benchmark::State &state) {
    Fd *conn = new TCPConn;
    for (auto _ : state)
        conn->setsockopt();
    delete conn;
}

static void BM_call_virtual_fn(benchmark::State &state) {
    Fd *conn = new TCPConn;
    for (auto _ : state)
        conn->close();
    delete conn;
}

未开启优化的情况下,虚函数的性能大概比普通函数低 30% 左右

2024-05-15T09:34:23+08:00
Running ./benchmark
Run on (20 X 4800 MHz CPU s)
CPU Caches:
  L1 Data 48 KiB (x10)
  L1 Instruction 32 KiB (x10)
  L2 Unified 1280 KiB (x10)
  L3 Unified 25600 KiB (x1)
Load Average: 0.37, 0.19, 0.08
***WARNING*** CPU scaling is enabled, the benchmark real time measurements may be noisy and will incur extra overhead.
-------------------------------------------------------------
Benchmark                   Time             CPU   Iterations
-------------------------------------------------------------
BM_call_normal_fn        1.35 ns         1.35 ns    516029631
BM_call_virtual_fn       1.74 ns         1.74 ns    410454516

使用 O2 的优化选项后,因为是非常简单的函数,普通函数直接被优化了,虚函数相比没有开优化也有提升。

-------------------------------------------------------------
Benchmark                   Time             CPU   Iterations
-------------------------------------------------------------
BM_call_normal_fn       0.000 ns        0.000 ns   1000000000000
BM_call_virtual_fn      0.309 ns        0.309 ns   2213725270

优化的点在于直接把空函数消除,但是虚函数调用那一套绕不过去,只是消除了部分 call 指令。

截取部分 benchmark 的汇编代码,setsockopt 完全被消除,只剩下 new/delete (也就是 _Znwm@PLT 和 _ZdlPvm@PLT)

.L241:
	movq	%rbp, %rdi
	call	_Znwm@PLT
	movq	16(%rbx), %rdx
	movq	%rax, %rcx
	testq	%rdx, %rdx
	jne	.L251
.L242:
	movq	%rcx, 8(%rbx)
	addq	$32, %rbx
	movq	%rbp, -8(%rbx)
	cmpq	%rbx, %r12
	je	.L239
	addq	$8, %rsp
	.cfi_remember_state
	.cfi_def_cfa_offset 40
	movq	%r13, %rsi
	movq	%r12, %rdi
	popq	%rbx
	.cfi_def_cfa_offset 32
	popq	%rbp
	.cfi_def_cfa_offset 24
	popq	%r12
	.cfi_def_cfa_offset 16
	popq	%r13
	.cfi_def_cfa_offset 8
	jmp	_ZdlPvm@PLT

虚函数 close 的反汇编代码如下,可以看到虚函数也被优化了,但是查表的过程被保留。

	call	_Znwm@PLT
	movl	$12345, 8(%rax)
	movq	%rax, %rbx
	leaq	16+_ZTV7TCPConn(%rip), %rax
	movq	%rax, (%rbx)
	movl	28(%rbp), %eax
	testl	%eax, %eax
	je	.L253
	movq	%rbp, %rdi
	call	_ZN9benchmark5State16StartKeepRunningEv@PLT
.L254:
	movq	%rbp, %rdi
	call	_ZN9benchmark5State17FinishKeepRunningEv@PLT
	movq	(%rbx), %rax
	leaq	_ZN7TCPConnD0Ev(%rip), %rdx
	movq	8(%rax), %rax
	cmpq	%rdx, %rax
	jne	.L255
	movq	%rbx, %rdi
	movl	$16, %esi
	popq	%rbx
	.cfi_remember_state
	.cfi_def_cfa_offset 24
	popq	%rbp
	.cfi_def_cfa_offset 16
	popq	%r12
	.cfi_def_cfa_offset 8
	jmp	_ZdlPvm@PLT
	.p2align 4,,10
	.p2align 3
.L253:
	.cfi_restore_state
	movq	16(%rbp), %r12
	movq	%rbp, %rdi
	call	_ZN9benchmark5State16StartKeepRunningEv@PLT
	testq	%r12, %r12
	jns	.L254

所以借助编译器的优化,除非是在CPU密集型的地方,否则虚函数该用还是用,性能损失并不大。

posted on 2024-05-21 15:01  文一路挖坑侠  阅读(4)  评论(0编辑  收藏  举报

导航