SimpleBLE

Concurrency within SimpleBLE

The threading and callback contract for SimpleBLE's synchronous API, including which threads run your callbacks and every timeout in the library.

SimpleBLE deliberately exposes a blocking, synchronous API. Calls like connect(), read(), or scan_for() do not return until the operation has completed, failed, or timed out. This is a design choice: control flow stays linear and understandable, there is no async runtime to integrate against, and the same mental model works identically from C++, Python, Java, or any other binding.

The cost of that choice is that the threading rules become part of the API contract. This page is that contract: which threads your callbacks run on, what you are allowed to call from inside a callback, and how long every operation can block.

Which thread runs your callbacks

SimpleBLE never invokes callbacks on your application's main thread. Every backend delivers callbacks — set_callback_on_scan_found, set_callback_on_scan_updated, notification/indication callbacks, connect/disconnect callbacks — from an internal thread owned by the library or the operating system:

PlatformCallback threadNotes
Linux (BlueZ)A single DBus dispatch thread owned by SimpleBLESpawned when the backend initializes; it pumps all DBus messages, signals, and method replies for the whole process. All callbacks are serialized on it.
macOS / iOSA serial CoreBluetooth dispatch queueOne serial queue per adapter. CoreBluetooth delegate events and your callbacks are serialized on it.
WindowsWinRT threadpool threadsAdvertisement and GATT events arrive on arbitrary threadpool threads belonging to the multithreaded apartment (MTA). Callbacks are not guaranteed to arrive on the same thread every time.
AndroidA single SimpleJNI runner thread owned by SimpleBLEJNI callbacks from the Android stack are enqueued onto this thread; all callbacks are serialized on it.

Two consequences apply on every platform:

  1. Thread safety is your responsibility. Any data your callback touches must be protected (mutex, atomic, thread-safe queue) if the rest of your application also touches it.
  2. Callbacks block the delivery pipeline. On Linux, macOS, and Android a single thread (or serial queue) delivers all events. While your callback runs, no other scan result, notification, or disconnection event can be delivered. Keep callbacks short.

Calling SimpleBLE from inside a callback

Because most SimpleBLE operations block until an event is delivered — and callbacks run on the very thread that delivers events — calling back into SimpleBLE from inside a callback is unsafe on most platforms, with consequences that range from delayed events to a deadlock depending on the backend.

The portable rule: treat callbacks like interrupt handlers. Copy the data you need, signal another thread (queue, condition variable, channel), and return. Do all SimpleBLE calls from your own threads.

The per-platform details:

Linux

Method calls to BlueZ — connect(), disconnect(), unpair(), read(), write_request(), write_command(), notify(), indicate(), unsubscribe(), scan_start(), scan_stop() — are sent asynchronously and their replies are pumped by the same dispatch thread that runs your callbacks. Calling any of them from inside a callback therefore stalls: the reply can never be processed, the call blocks for the full 30-second DBus reply timeout, and then throws.

Property reads — rssi(), is_connected(), is_paired(), identifier(), address() and similar — use blocking DBus calls that do not depend on the dispatch thread and are safe to call from callbacks.

macOS / iOS

GATT operations block the calling thread in a poll loop until the corresponding CoreBluetooth delegate event fires — and delegate events are delivered on the same serial queue your callback is running on. Calling read(), write_request(), notify(), unsubscribe(), or any other peripheral operation from inside a notification or scan callback deadlocks permanently: most of these waits are unbounded, so no timeout will unblock the call. Do not call peripheral methods from callbacks on macOS.

Windows

SimpleBLE marshals its own WinRT calls onto a dedicated internal MTA thread, which is separate from the threadpool threads that deliver your callbacks. Calling SimpleBLE methods from inside a callback therefore does not self-deadlock, but it blocks a threadpool event thread and can delay other events; short calls are tolerable, long blocking calls (like connect() or a blocking disconnect()) should still be moved to your own thread.

Android

Operation completions are delivered through the same single runner thread that executes your callbacks. Calling a blocking operation from inside a callback stalls until its timeout expires and then throws: 5 seconds for GATT reads/writes, 8 seconds for connect()/disconnect().

Notification callbacks are the common trap

The most frequent mistake on every platform is issuing a read() or write_request() from inside a notification callback — for example, responding to a device's notification with a command. On macOS this deadlocks forever; on Linux it stalls 30 seconds and throws; on Android it stalls 5 seconds and throws. Push the notification payload into a queue and let a worker thread issue the response instead.

Timeout reference

Every blocking operation in SimpleBLE eventually gives up. The table below lists every wait in the library, per backend. "Hardcoded" means the value cannot be changed through SimpleBLE::Config.

Linux (BlueZ backend)

OperationTimeoutConfigurable
Any BlueZ method reply (connect, read, write, notify, scan control, …)30 sHardcoded (internal DBus reply timeout)
Connect: wait for connection + service resolution2 s per attempt, 5 attemptsConfig::SimpleBluez::connection_timeout
Disconnect: wait for disconnection1 s per attempt, 5 attemptsConfig::SimpleBluez::disconnection_timeout
Unsubscribe: wait for notification session to stop5 s (50 ms poll)Hardcoded

On failure after all attempts, connect()/disconnect() throw SimpleBLE::Exception::OperationFailed.

macOS / iOS (CoreBluetooth backend)

OperationTimeoutConfigurable
Connect5 sHardcoded
Adapter power on/off, central manager startup5 sHardcoded
DisconnectUnbounded
Service / characteristic / descriptor discoveryUnbounded
Read, write, notify, indicate, unsubscribe, descriptor opsUnbounded

The unbounded waits poll every 10 ms until the corresponding CoreBluetooth delegate event arrives. In practice CoreBluetooth reports errors promptly, but if the OS never delivers an event (for example, the peripheral vanished mid-operation) the call can hang indefinitely.

Windows (WinRT backend)

OperationTimeoutConfigurable
Disconnect (blocking mode)10 sOnly reached when Config::WinRT::use_deferred_disconnect = false
Scan start: wait for pending deferred disconnects5 s (100 ms poll)Only when use_deferred_disconnect = true (default)
Scan stop acknowledgment1 s (silent on timeout)Hardcoded
GATT operationsUnbounded (WinRT async completion)

Android

OperationTimeoutConfigurable
Connect (including service discovery)8 sHardcoded
Disconnect8 sHardcoded
Characteristic read / write5 sHardcoded
Descriptor read / write5 sHardcoded

Configuration knobs

SimpleBLE::Config (in simpleble/Config.h) exposes the tunable subset of the behavior above. All values must be set before calling Adapter::get_adapters() — changes made after the backend is instantiated may not take effect.

KnobDefaultEffect
Config::SimpleBluez::connection_timeout2 sPer-attempt wait for connection + service resolution on Linux (5 attempts).
Config::SimpleBluez::disconnection_timeout1 sPer-attempt wait for disconnection on Linux (5 attempts).
Config::SimpleBluez::use_system_bustrueConnect to the DBus system bus (new BlueZ backend only).
Config::SimpleBluez::use_legacy_bluez_backendfalseFall back to the legacy BlueZ backend.
Config::WinRT::use_deferred_disconnecttrueDisconnects return immediately and clean up in the background instead of blocking up to 10 s.
Config::WinRT::experimental_use_own_mta_apartmenttrueSimpleBLE runs its own MTA thread for WinRT calls. Disabling runs WinRT calls on your calling thread.
Config::Android::connection_priority_requestDISABLEDRequest a connection priority (BALANCED, HIGH, LOW_POWER, DCK) after connecting.

The 30-second DBus reply timeout on Linux is internal to the DBus layer and is not exposed through SimpleBLE::Config.

UI applications and event loops

Using SimpleBLE with UI frameworks (Qt, WxWidgets, WinForms, Unity, …) requires special care. Most UI frameworks are not thread-safe and require all UI updates to happen on the main/UI thread — and as established above, SimpleBLE callbacks never run there.

If you attempt to update a UI element directly from a SimpleBLE callback, your application might crash or freeze. Instead:

  • Use the UI framework's message passing or "invoke on main thread" mechanism (QMetaObject::invokeMethod, Control.BeginInvoke, Unity's main-thread dispatcher patterns, …).
  • Or signal an event that the main thread's loop picks up and processes.

The same applies in reverse: don't run blocking SimpleBLE operations (connect(), scan_for()) on the UI thread, or your interface will freeze for the duration. A dedicated BLE worker thread that owns all SimpleBLE calls, fed by a message queue and feeding results back to the UI thread, is the pattern that works in every framework.

Rules of thumb

  1. Keep callbacks lightweight: copy data out and return.
  2. Never call SimpleBLE operations from inside a callback; hand the work to another thread.
  3. Own all blocking SimpleBLE calls from a dedicated worker thread, not the UI thread.
  4. Protect any state shared between callbacks and the rest of your application.
  5. Set Config values before touching Adapter::get_adapters().

On this page