Неожиданный пример гонки в С++.
Возьмем
такой код.
struct Task {
virtual ~Task() = default;
virtual void run() = 0;
};
class AutorunTask : public Task {
public:
AutorunTask() : t_([this]() {
this->run();
}) {}
~AutorunTask() { t_.join(); }
private:
std::thread t_;
};
struct Impl : public AutorunTask {
void run() override {
puts("The string");
}
};
int main(){
Impl impl;
std::this_thread::sleep_for(1ms);
}
Что мы тут видим?
Класс Task с чисто виртуальной функцией. Интерфейс, типичный интерфейс.
Промежуточный класс AutorunTask, который в конструкторе запускает в конструкторе виртуальный метод, и в деструкторе дожидается его окончания.
И в конце концов реализация виртуальной функции, которая и вызывается.
И в таком виде код работает, и выводит
Program returned: 0
The string
Но зачем мы поставили sleep в конце? Дело в том, что если его закомментировать, то код упадет с ошибкой.
Program returned: 139
pure virtual method called
terminate called without an active exception
Program terminated with signal: SIGSEGV
Итак. Давайте разбираться.
Можно ли вообще в конструкторе/деструкторе использовать виртуальные функции?
Смотрим
paragraph 4 [
ISO/IEC 14882-2014]
Member functions, including virtual functions, can be called during construction or destruction. When a virtual function is called directly or indirectly from a constructor or from a destructor, including during the construction or destruction of the class’s non-static data members, and the object to which the call applies is the object (call it x) under construction or destruction, the function called is the final overrider in the constructor’s or destructor’s class and not one overriding it in a more-derived class. If the virtual function call uses an explicit class member access and the object expression refers to the complete object of x or one of that object’s base class subobjects but not x or one of its base class subobjects, the behavior is undefined.
Тоесть, можно.
Что же они запускают? Ведь функция в классе-наследнике может обращаться к несконструированному классу?
На самом деле, в конструкторе и деструкторе вызывается реализация "текущего" класса. Смотрим
пример.
struct A {
virtual void f() {
puts("A");
}
A() { f(); }
};
struct B : public A {
public:
void f() override {
puts("B");
}
B() { f(); }
};
int main(){
B b;
}
Это код выводит
Program returned: 0
A
B
конструктор первого класса выведет A. Второго B.
А как это достигается? На самом деле, в программе
переписывается указатель в таблице виртуальных вызовов.
Ооооо, вот тут и возникает гонка.
AutorunTask::~AutorunTask() [base object destructor]:
push rbx
mov rbx, rdi
lea rax, [rip + vtable for AutorunTask+16]
mov qword ptr [rdi], rax
add rbx, 8
mov rdi, rbx
call std::thread::join()@PLT
Вот тут перед непосредственным вызовом деструктора устанавливается другой указатель в таблице виртальных вызовов.
vtable for AutorunTask:
.quad 0
.quad typeinfo for AutorunTask
.quad AutorunTask::~AutorunTask() [base object destructor]
.quad AutorunTask::~AutorunTask() [deleting destructor]
.quad __cxa_pure_virtual
А вызов внутри лямбды происходит не конкретной функции, а по указателю из таблицы виртуальных вызовов.
std::thread::_State_impl<std::thread::_Invoker<std::tuple<AutorunTask::AutorunTask()::'lambda'()>>>::_M_run() [complete object constructor]:
mov rdi, qword ptr [rdi + 8]
mov rax, qword ptr [rdi]
jmp qword ptr [rax + 16]
И дальше происходит гонка. Если деструктор вызовется
раньше, чем запустится поток (версия без `sleep`), то вызовется чистовиртуальный метод, что приведет к ошибке.
А если деструктор вызывается позже (версия со `sleep`), то все отработает штатно.
Добро пожаловать в красивые примеры нашей любимой дурки. 🤡