Commit ae3fb04f authored by Olga Sharonova's avatar Olga Sharonova Committed by Commit Bot

Optimization of realtime audio threads on Mac

(Behind a flag)

* Configures renderer-side RT audio threads based on the audio buffer
duration, the same way CoreAudio does for audio device threads.

* This CL covers only audio playback and capture which use
base::PlatformThread directly (this is most of the audio paths).

* WebAudio worklet and audio loopback could probably also benefit from
such changes, but it requires a separate effort of exposing API in
blink::Thread/SimpleThread (WebAudio) and base::Thread (loopback).

* The change is disabled by default. We'll run a finch experiment
to evaluate it.

*If we do decide to keep it, as part of cleaning up the experiment,
we will remove the new virtual from PlatformThread::Delegate and move
it to be passed along with the ThreadPriority (presumably in some sort
of struct, but TBD).

Doc: https://docs.google.com/document/d/12grOIZ4ootmJb5RYoe3GuJDCzYTjtNiuGIG6PvCz-78/edit?usp=sharing

------
API discussion:

V3 of API (PS10)
* Removed a data member from Delegate

V2 of API (PS8)
* Looks a bit better

V1 of API (PS7)
* I'm not a fan of having |audio_buffer_duration| in the thread API,
but left it for now, to make it clear what exactly is going on.


Bug: 1112952
Change-Id: I43c0b92d60622c74b9eede4e9feba9142cbca9fd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2398661
Commit-Queue: Olga Sharonova <olka@chromium.org>
Reviewed-by: default avatarDale Curtis <dalecurtis@chromium.org>
Reviewed-by: default avatarDaniel Cheng <dcheng@chromium.org>
Cr-Commit-Position: refs/heads/master@{#809308}
parent ff8d2bea
...@@ -769,6 +769,7 @@ component("base") { ...@@ -769,6 +769,7 @@ component("base") {
"threading/thread_restrictions.h", "threading/thread_restrictions.h",
"threading/thread_task_runner_handle.cc", "threading/thread_task_runner_handle.cc",
"threading/thread_task_runner_handle.h", "threading/thread_task_runner_handle.h",
"threading/threading_features.h",
"threading/watchdog.cc", "threading/watchdog.cc",
"threading/watchdog.h", "threading/watchdog.h",
"time/clock.cc", "time/clock.cc",
......
...@@ -32,6 +32,16 @@ void PlatformThread::SetCurrentThreadPriority(ThreadPriority priority) { ...@@ -32,6 +32,16 @@ void PlatformThread::SetCurrentThreadPriority(ThreadPriority priority) {
SetCurrentThreadPriorityImpl(priority); SetCurrentThreadPriorityImpl(priority);
} }
TimeDelta PlatformThread::GetRealtimePeriod(Delegate* delegate) {
if (g_use_thread_priorities.load())
return delegate->GetRealtimePeriod();
return TimeDelta();
}
TimeDelta PlatformThread::Delegate::GetRealtimePeriod() {
return TimeDelta();
}
namespace internal { namespace internal {
void InitializeThreadPrioritiesFeature() { void InitializeThreadPrioritiesFeature() {
...@@ -43,6 +53,10 @@ void InitializeThreadPrioritiesFeature() { ...@@ -43,6 +53,10 @@ void InitializeThreadPrioritiesFeature() {
!FeatureList::IsEnabled(kThreadPrioritiesFeature)) { !FeatureList::IsEnabled(kThreadPrioritiesFeature)) {
g_use_thread_priorities.store(false); g_use_thread_priorities.store(false);
} }
#if defined(OS_APPLE)
PlatformThread::InitializeOptimizedRealtimeThreadingFeature();
#endif
} }
} // namespace internal } // namespace internal
......
...@@ -123,6 +123,12 @@ class BASE_EXPORT PlatformThread { ...@@ -123,6 +123,12 @@ class BASE_EXPORT PlatformThread {
// ThreadMain method will be called on the newly created thread. // ThreadMain method will be called on the newly created thread.
class BASE_EXPORT Delegate { class BASE_EXPORT Delegate {
public: public:
// The interval at which the thread expects to have work to do. Zero if
// unknown. (Example: audio buffer duration for real-time audio.) Is used to
// optimize the thread real-time behavior. Is called on the newly created
// thread before ThreadMain().
virtual TimeDelta GetRealtimePeriod();
virtual void ThreadMain() = 0; virtual void ThreadMain() = 0;
protected: protected:
...@@ -221,6 +227,9 @@ class BASE_EXPORT PlatformThread { ...@@ -221,6 +227,9 @@ class BASE_EXPORT PlatformThread {
static ThreadPriority GetCurrentThreadPriority(); static ThreadPriority GetCurrentThreadPriority();
// Returns a realtime period provided by |delegate|.
static TimeDelta GetRealtimePeriod(Delegate* delegate);
#if defined(OS_LINUX) || defined(OS_CHROMEOS) #if defined(OS_LINUX) || defined(OS_CHROMEOS)
// Toggles a specific thread's priority at runtime. This can be used to // Toggles a specific thread's priority at runtime. This can be used to
// change the priority of a thread in a different process and will fail // change the priority of a thread in a different process and will fail
...@@ -248,6 +257,15 @@ class BASE_EXPORT PlatformThread { ...@@ -248,6 +257,15 @@ class BASE_EXPORT PlatformThread {
// explicitly set default size then returns 0. // explicitly set default size then returns 0.
static size_t GetDefaultThreadStackSize(); static size_t GetDefaultThreadStackSize();
#if defined(OS_APPLE)
// Initializes realtime threading based on kOptimizedRealtimeThreadingMac
// feature status.
static void InitializeOptimizedRealtimeThreadingFeature();
// Stores the period value in TLS.
static void SetCurrentThreadRealtimePeriodValue(TimeDelta realtime_period);
#endif
private: private:
static void SetCurrentThreadPriorityImpl(ThreadPriority priority); static void SetCurrentThreadPriorityImpl(ThreadPriority priority);
......
...@@ -12,18 +12,22 @@ ...@@ -12,18 +12,22 @@
#include <sys/resource.h> #include <sys/resource.h>
#include <algorithm> #include <algorithm>
#include <atomic>
#include "base/feature_list.h"
#include "base/lazy_instance.h" #include "base/lazy_instance.h"
#include "base/logging.h" #include "base/logging.h"
#include "base/mac/foundation_util.h" #include "base/mac/foundation_util.h"
#include "base/mac/mach_logging.h" #include "base/mac/mach_logging.h"
#include "base/threading/thread_id_name_manager.h" #include "base/threading/thread_id_name_manager.h"
#include "base/threading/threading_features.h"
#include "build/build_config.h" #include "build/build_config.h"
namespace base { namespace base {
namespace { namespace {
NSString* const kThreadPriorityKey = @"CrThreadPriorityKey"; NSString* const kThreadPriorityKey = @"CrThreadPriorityKey";
NSString* const kRealtimePeriodNsKey = @"CrRealtimePeriodNsKey";
} // namespace } // namespace
// If Cocoa is to be used on more than one thread, it must know that the // If Cocoa is to be used on more than one thread, it must know that the
...@@ -60,47 +64,73 @@ void PlatformThread::SetName(const std::string& name) { ...@@ -60,47 +64,73 @@ void PlatformThread::SetName(const std::string& name) {
pthread_setname_np(shortened_name.c_str()); pthread_setname_np(shortened_name.c_str());
} }
namespace { // Whether optimized realt-time thread config should be used for audio.
const Feature kOptimizedRealtimeThreadingMac{"OptimizedRealtimeThreadingMac",
FEATURE_DISABLED_BY_DEFAULT};
// Enables time-contraint policy and priority suitable for low-latency, namespace {
// glitch-resistant audio. // PlatformThread::SetCurrentThreadRealtimePeriodValue() doesn't query the state
void SetPriorityRealtimeAudio() { // of kOptimizedRealtimeThreadingMac feature directly because FeatureList
// Increase thread priority to real-time. // initialization is not always synchronized with
// PlatformThread::SetCurrentThreadRealtimePeriodValue(). The initial value
// should match the default state of kOptimizedRealtimeThreadingMac.
std::atomic<bool> g_use_optimized_realtime_threading(false);
// Please note that the thread_policy_set() calls may fail in } // namespace
// rare cases if the kernel decides the system is under heavy load
// and is unable to handle boosting the thread priority.
// In these cases we just return early and go on with life.
mach_port_t mach_thread_id = // static
pthread_mach_thread_np(PlatformThread::CurrentHandle().platform_handle()); void PlatformThread::InitializeOptimizedRealtimeThreadingFeature() {
// A DCHECK is triggered on FeatureList initialization if the state of a
// feature has been checked before. To avoid triggering this DCHECK in unit
// tests that call this before initializing the FeatureList, only check the
// state of the feature if the FeatureList is initialized.
if (FeatureList::GetInstance()) {
g_use_optimized_realtime_threading.store(
FeatureList::IsEnabled(kOptimizedRealtimeThreadingMac));
}
}
// Make thread fixed priority. // static
thread_extended_policy_data_t policy; void PlatformThread::SetCurrentThreadRealtimePeriodValue(
policy.timeshare = 0; // Set to 1 for a non-fixed thread. TimeDelta realtime_period) {
kern_return_t result = if (g_use_optimized_realtime_threading.load()) {
thread_policy_set(mach_thread_id, [[NSThread currentThread] threadDictionary][kRealtimePeriodNsKey] =
THREAD_EXTENDED_POLICY, @(realtime_period.InNanoseconds());
reinterpret_cast<thread_policy_t>(&policy),
THREAD_EXTENDED_POLICY_COUNT);
if (result != KERN_SUCCESS) {
MACH_DVLOG(1, result) << "thread_policy_set";
return;
} }
}
// Set to relatively high priority. namespace {
thread_precedence_policy_data_t precedence;
precedence.importance = 63; TimeDelta GetCurrentThreadRealtimePeriod() {
result = thread_policy_set(mach_thread_id, NSNumber* period = mac::ObjCCast<NSNumber>(
THREAD_PRECEDENCE_POLICY, [[NSThread currentThread] threadDictionary][kRealtimePeriodNsKey]);
reinterpret_cast<thread_policy_t>(&precedence),
THREAD_PRECEDENCE_POLICY_COUNT); return period ? TimeDelta::FromNanoseconds(period.longLongValue)
if (result != KERN_SUCCESS) { : TimeDelta();
MACH_DVLOG(1, result) << "thread_policy_set"; }
return;
// Calculates time constrints for THREAD_TIME_CONSTRAINT_POLICY.
// |realtime_period| is used as a base if it's non-zero.
// Otherwise we fall back to empirical values.
thread_time_constraint_policy_data_t GetTimeConstraints(
TimeDelta realtime_period) {
thread_time_constraint_policy_data_t time_constraints;
mach_timebase_info_data_t tb_info;
mach_timebase_info(&tb_info);
if (!realtime_period.is_zero()) {
uint32_t abs_realtime_period =
saturated_cast<uint32_t>(realtime_period.InNanoseconds() *
(double(tb_info.denom) / tb_info.numer));
time_constraints.period = abs_realtime_period;
time_constraints.computation = abs_realtime_period / 2;
time_constraints.constraint = abs_realtime_period;
time_constraints.preemptible = YES;
return time_constraints;
} }
// Most important, set real-time constraints. // Empirical configuration.
// Define the guaranteed and max fraction of time for the audio thread. // Define the guaranteed and max fraction of time for the audio thread.
// These "duty cycle" values can range from 0 to 1. A value of 0.5 // These "duty cycle" values can range from 0 to 1. A value of 0.5
...@@ -124,20 +154,57 @@ void SetPriorityRealtimeAudio() { ...@@ -124,20 +154,57 @@ void SetPriorityRealtimeAudio() {
// Get the conversion factor from milliseconds to absolute time // Get the conversion factor from milliseconds to absolute time
// which is what the time-constraints call needs. // which is what the time-constraints call needs.
mach_timebase_info_data_t tb_info; double ms_to_abs_time = double(tb_info.denom) / tb_info.numer * 1000000;
mach_timebase_info(&tb_info);
double ms_to_abs_time =
(static_cast<double>(tb_info.denom) / tb_info.numer) * 1000000;
thread_time_constraint_policy_data_t time_constraints;
time_constraints.period = kTimeQuantum * ms_to_abs_time; time_constraints.period = kTimeQuantum * ms_to_abs_time;
time_constraints.computation = kAudioTimeNeeded * ms_to_abs_time; time_constraints.computation = kAudioTimeNeeded * ms_to_abs_time;
time_constraints.constraint = kMaxTimeAllowed * ms_to_abs_time; time_constraints.constraint = kMaxTimeAllowed * ms_to_abs_time;
time_constraints.preemptible = 0; time_constraints.preemptible = 0;
return time_constraints;
}
// Enables time-contraint policy and priority suitable for low-latency,
// glitch-resistant audio.
void SetPriorityRealtimeAudio(TimeDelta realtime_period) {
// Increase thread priority to real-time.
// Please note that the thread_policy_set() calls may fail in
// rare cases if the kernel decides the system is under heavy load
// and is unable to handle boosting the thread priority.
// In these cases we just return early and go on with life.
mach_port_t mach_thread_id =
pthread_mach_thread_np(PlatformThread::CurrentHandle().platform_handle());
// Make thread fixed priority.
thread_extended_policy_data_t policy;
policy.timeshare = 0; // Set to 1 for a non-fixed thread.
kern_return_t result = thread_policy_set(
mach_thread_id, THREAD_EXTENDED_POLICY,
reinterpret_cast<thread_policy_t>(&policy), THREAD_EXTENDED_POLICY_COUNT);
if (result != KERN_SUCCESS) {
MACH_DVLOG(1, result) << "thread_policy_set";
return;
}
// Set to relatively high priority.
thread_precedence_policy_data_t precedence;
precedence.importance = 63;
result = thread_policy_set(mach_thread_id, THREAD_PRECEDENCE_POLICY,
reinterpret_cast<thread_policy_t>(&precedence),
THREAD_PRECEDENCE_POLICY_COUNT);
if (result != KERN_SUCCESS) {
MACH_DVLOG(1, result) << "thread_policy_set";
return;
}
// Most important, set real-time constraints.
thread_time_constraint_policy_data_t time_constraints =
GetTimeConstraints(realtime_period);
result = result =
thread_policy_set(mach_thread_id, thread_policy_set(mach_thread_id, THREAD_TIME_CONSTRAINT_POLICY,
THREAD_TIME_CONSTRAINT_POLICY,
reinterpret_cast<thread_policy_t>(&time_constraints), reinterpret_cast<thread_policy_t>(&time_constraints),
THREAD_TIME_CONSTRAINT_POLICY_COUNT); THREAD_TIME_CONSTRAINT_POLICY_COUNT);
MACH_DVLOG_IF(1, result != KERN_SUCCESS, result) << "thread_policy_set"; MACH_DVLOG_IF(1, result != KERN_SUCCESS, result) << "thread_policy_set";
...@@ -181,7 +248,7 @@ void PlatformThread::SetCurrentThreadPriorityImpl(ThreadPriority priority) { ...@@ -181,7 +248,7 @@ void PlatformThread::SetCurrentThreadPriorityImpl(ThreadPriority priority) {
break; break;
} }
case ThreadPriority::REALTIME_AUDIO: case ThreadPriority::REALTIME_AUDIO:
SetPriorityRealtimeAudio(); SetPriorityRealtimeAudio(GetCurrentThreadRealtimePeriod());
DCHECK_EQ([[NSThread currentThread] threadPriority], 1.0); DCHECK_EQ([[NSThread currentThread] threadPriority], 1.0);
break; break;
} }
......
...@@ -67,6 +67,12 @@ void* ThreadFunc(void* params) { ...@@ -67,6 +67,12 @@ void* ThreadFunc(void* params) {
base::ThreadRestrictions::SetSingletonAllowed(false); base::ThreadRestrictions::SetSingletonAllowed(false);
#if !defined(OS_NACL) #if !defined(OS_NACL)
#if defined(OS_APPLE)
PlatformThread::SetCurrentThreadRealtimePeriodValue(
PlatformThread::GetRealtimePeriod(delegate));
#endif
// Threads on linux/android may inherit their priority from the thread // Threads on linux/android may inherit their priority from the thread
// where they were created. This explicitly sets the priority of all new // where they were created. This explicitly sets the priority of all new
// threads. // threads.
......
...@@ -8,7 +8,9 @@ ...@@ -8,7 +8,9 @@
#include "base/process/process.h" #include "base/process/process.h"
#include "base/stl_util.h" #include "base/stl_util.h"
#include "base/synchronization/waitable_event.h" #include "base/synchronization/waitable_event.h"
#include "base/test/scoped_feature_list.h"
#include "base/threading/platform_thread.h" #include "base/threading/platform_thread.h"
#include "base/threading/threading_features.h"
#include "build/build_config.h" #include "build/build_config.h"
#include "testing/gtest/include/gtest/gtest.h" #include "testing/gtest/include/gtest/gtest.h"
...@@ -19,6 +21,13 @@ ...@@ -19,6 +21,13 @@
#include "base/threading/platform_thread_win.h" #include "base/threading/platform_thread_win.h"
#endif #endif
#if defined(OS_APPLE)
#include <mach/mach.h>
#include <mach/mach_time.h>
#include <mach/thread_policy.h>
#include "base/time/time.h"
#endif
namespace base { namespace base {
// Trivial tests that thread runs and doesn't crash on create, join, or detach - // Trivial tests that thread runs and doesn't crash on create, join, or detach -
...@@ -407,4 +416,128 @@ TEST(PlatformThreadTest, GetDefaultThreadStackSize) { ...@@ -407,4 +416,128 @@ TEST(PlatformThreadTest, GetDefaultThreadStackSize) {
#endif #endif
} }
#if defined(OS_APPLE)
namespace {
class RealtimeTestThread : public FunctionTestThread {
public:
explicit RealtimeTestThread(TimeDelta realtime_period)
: realtime_period_(realtime_period) {}
~RealtimeTestThread() override = default;
private:
RealtimeTestThread(const RealtimeTestThread&) = delete;
RealtimeTestThread& operator=(const RealtimeTestThread&) = delete;
TimeDelta GetRealtimePeriod() final { return realtime_period_; }
// Verifies the realtime thead configuration.
void RunTest() override {
EXPECT_EQ(PlatformThread::GetCurrentThreadPriority(),
ThreadPriority::REALTIME_AUDIO);
mach_port_t mach_thread_id = pthread_mach_thread_np(
PlatformThread::CurrentHandle().platform_handle());
// |count| and |get_default| chosen impirically so that
// time_constraints_buffer[0] would store the last constraints that were
// applied.
const int kPolicyCount = 32;
thread_time_constraint_policy_data_t time_constraints_buffer[kPolicyCount];
mach_msg_type_number_t count = kPolicyCount;
boolean_t get_default = 0;
kern_return_t result = thread_policy_get(
mach_thread_id, THREAD_TIME_CONSTRAINT_POLICY,
reinterpret_cast<thread_policy_t>(time_constraints_buffer), &count,
&get_default);
EXPECT_EQ(result, KERN_SUCCESS);
const thread_time_constraint_policy_data_t& time_constraints =
time_constraints_buffer[0];
mach_timebase_info_data_t tb_info;
mach_timebase_info(&tb_info);
if (FeatureList::IsEnabled(kOptimizedRealtimeThreadingMac) &&
!realtime_period_.is_zero()) {
uint32_t abs_realtime_period = saturated_cast<uint32_t>(
realtime_period_.InNanoseconds() *
(static_cast<double>(tb_info.denom) / tb_info.numer));
EXPECT_EQ(time_constraints.period, abs_realtime_period);
EXPECT_EQ(time_constraints.computation, abs_realtime_period / 2);
EXPECT_EQ(time_constraints.constraint, abs_realtime_period);
EXPECT_TRUE(time_constraints.preemptible);
} else {
// Old-style empirical values.
const double kTimeQuantum = 2.9;
const double kAudioTimeNeeded = 0.75 * kTimeQuantum;
const double kMaxTimeAllowed = 0.85 * kTimeQuantum;
// Get the conversion factor from milliseconds to absolute time
// which is what the time-constraints returns.
double ms_to_abs_time = double(tb_info.denom) / tb_info.numer * 1000000;
EXPECT_EQ(time_constraints.period,
saturated_cast<uint32_t>(kTimeQuantum * ms_to_abs_time));
EXPECT_EQ(time_constraints.computation,
saturated_cast<uint32_t>(kAudioTimeNeeded * ms_to_abs_time));
EXPECT_EQ(time_constraints.constraint,
saturated_cast<uint32_t>(kMaxTimeAllowed * ms_to_abs_time));
EXPECT_FALSE(time_constraints.preemptible);
}
}
const TimeDelta realtime_period_;
};
class RealtimePlatformThreadTest : public testing::TestWithParam<TimeDelta> {
protected:
void VerifyRealtimeConfig(TimeDelta period) {
RealtimeTestThread thread(period);
PlatformThreadHandle handle;
ASSERT_FALSE(thread.IsRunning());
ASSERT_TRUE(PlatformThread::CreateWithPriority(
0, &thread, &handle, ThreadPriority::REALTIME_AUDIO));
thread.WaitForTerminationReady();
ASSERT_TRUE(thread.IsRunning());
thread.MarkForTermination();
PlatformThread::Join(handle);
ASSERT_FALSE(thread.IsRunning());
}
};
TEST_P(RealtimePlatformThreadTest, RealtimeAudioConfigMacFeatureOn) {
test::ScopedFeatureList feature_list;
feature_list.InitAndEnableFeature(kOptimizedRealtimeThreadingMac);
PlatformThread::InitializeOptimizedRealtimeThreadingFeature();
VerifyRealtimeConfig(GetParam());
}
TEST_P(RealtimePlatformThreadTest, RealtimeAudioConfigMacFeatureOff) {
test::ScopedFeatureList feature_list;
feature_list.InitAndDisableFeature(kOptimizedRealtimeThreadingMac);
PlatformThread::InitializeOptimizedRealtimeThreadingFeature();
VerifyRealtimeConfig(GetParam());
}
INSTANTIATE_TEST_SUITE_P(RealtimePlatformThreadTest,
RealtimePlatformThreadTest,
testing::Values(TimeDelta(),
TimeDelta::FromSeconds(256.0 / 48000),
TimeDelta::FromMilliseconds(5),
TimeDelta::FromMilliseconds(10),
TimeDelta::FromSeconds(1024.0 / 44100),
TimeDelta::FromSeconds(1024.0 /
16000)));
} // namespace
#endif
} // namespace base } // namespace base
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#ifndef BASE_THREADING_THREADING_FEATURES_H_
#define BASE_THREADING_THREADING_FEATURES_H_
#include "base/base_export.h"
#include "build/build_config.h"
namespace base {
struct Feature;
#if defined(OS_APPLE)
extern const BASE_EXPORT Feature kOptimizedRealtimeThreadingMac;
#endif
} // namespace base
#endif // BASE_THREADING_THREADING_FEATURES_H_
...@@ -58,6 +58,10 @@ AudioDeviceThread::~AudioDeviceThread() { ...@@ -58,6 +58,10 @@ AudioDeviceThread::~AudioDeviceThread() {
base::PlatformThread::Join(thread_handle_); base::PlatformThread::Join(thread_handle_);
} }
base::TimeDelta AudioDeviceThread::GetRealtimePeriod() {
return callback_->buffer_duration();
}
void AudioDeviceThread::ThreadMain() { void AudioDeviceThread::ThreadMain() {
base::PlatformThread::SetName(thread_name_); base::PlatformThread::SetName(thread_name_);
callback_->InitializeOnAudioThread(); callback_->InitializeOnAudioThread();
......
...@@ -42,6 +42,10 @@ class MEDIA_EXPORT AudioDeviceThread : public base::PlatformThread::Delegate { ...@@ -42,6 +42,10 @@ class MEDIA_EXPORT AudioDeviceThread : public base::PlatformThread::Delegate {
// Called whenever we receive notifications about pending input data. // Called whenever we receive notifications about pending input data.
virtual void Process(uint32_t pending_data) = 0; virtual void Process(uint32_t pending_data) = 0;
base::TimeDelta buffer_duration() const {
return audio_parameters_.GetBufferDuration();
}
protected: protected:
virtual ~Callback(); virtual ~Callback();
...@@ -75,6 +79,7 @@ class MEDIA_EXPORT AudioDeviceThread : public base::PlatformThread::Delegate { ...@@ -75,6 +79,7 @@ class MEDIA_EXPORT AudioDeviceThread : public base::PlatformThread::Delegate {
~AudioDeviceThread() override; ~AudioDeviceThread() override;
private: private:
base::TimeDelta GetRealtimePeriod() final;
void ThreadMain() final; void ThreadMain() final;
Callback* const callback_; Callback* const callback_;
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment