-
Notifications
You must be signed in to change notification settings - Fork 97
Threading and concurrency
Every transport is safe to call from several threads. What differs is whether the commands actually overlap, and the library states this rather than leaving it to be discovered — the failure mode of guessing wrong on a transport that cannot multiplex is a wrong answer, not an exception.
| Transports | Behaviour | |
|---|---|---|
| Concurrent |
Api, ApiSsl, Rest, RestSsl, WinboxNative, WinboxNativeMac
|
several commands may be in flight on one connection; each caller gets its own reply |
| Serialized |
Telnet, Ssh, MacTelnet, WinboxCli, WinboxCliMac
|
they drive a single request/reply terminal, so commands queue |
Nothing has to be configured on any of them. The one setting that exists is on the binary API, and it already defaults to the safe value — see below.
Whether concurrency is faster depends on round-trip latency, not on the group: it pays off where a round trip is expensive (WinBox over the MAC layer most of all) and can cost slightly more than it saves on a transport whose round trip is already about a millisecond.
The router echoes back the .tag a command was sent with, and that is the only thing tying a reply to
the caller that asked for it. tik4net sends it on synchronous commands when SendTagWithSyncCommand —
on ITikTaggedConnection, the interface it lives on; there is no
plain-ITikConnection extension method for it — is set. It defaults to true since 4.0, so
multi-threaded use of one connection needs no setup:
((ITikTaggedConnection)connection).SendTagWithSyncCommand = false; // API only — opts into the untagged wire formatWithout the tag, concurrent synchronous commands cross-deliver rows between callers — you get a
Missing word with name '…' or a row belonging to someone else's command, not an error saying what
happened. Only turn it off on a connection you know will stay single-threaded. Asynchronous commands
(ExecuteWithCallback / LoadWithCallback) are always tagged and are unaffected.
Every command is an independent HTTP request on a shared HttpClient. There is no channel to serialize
and no correlation to get wrong. Up to eight requests are on the wire at once; a further one waits for a
slot, and the wait counts against its timeout.
The cap exists because RouterOS answers only the first of several requests queued on one HTTP connection.
On .NET Framework HttpClient queues a request on a busy connection once the per-host connection limit
(2 by default) is reached, so the connection raises that limit for the router's address when it opens.
The limit here is the router, not the client. RouterOS buffers a REST response until the command
completes and keeps that REST session busy meanwhile, so a long-running command blocks the requests
behind it: measured on 7.23.2, requests issued after a count=30 ping had been abandoned at 5 s timed
out for the remaining ~23 s the router took to finish it — and aborting the socket does not free the
session. Ordinary commands are short and overlap fine; keep long monitors off a connection that also
carries them. See REST connection.
The M2 channel is multiplexed: every request carries an id the router echoes back, and a single reader loop hands each reply to the caller waiting for it. A running monitor therefore does not block CRUD on another thread.
Two qualifications:
-
The concurrency is between independent operations, not within one. A read that needs reference
resolution still issues its follow-up
getallcalls in sequence, because each depends on the previous reply. -
Openis the exception. Authentication, the version probe and the.jgcatalog load run lockstep on the raw channel before the reader loop starts, so open a connection from one thread and share it afterwards.
On the MAC variant the carrier underneath is still one acknowledged byte stream, so a lost packet stalls whatever is queued behind it: concurrency hides the per-packet round trip there, it does not remove it. Prefer the TCP transport for high-rate concurrent work when an IP route exists.
Telnet, Ssh, MacTelnet, WinboxCli and WinboxCliMac drive a RouterOS terminal, and a terminal
carries one conversation. The connection takes an internal semaphore around each whole command, so extra
callers queue behind the one holding it.
That is by design and not a limitation waiting to be lifted. A gate that failed to hold here would not raise an error — it would interleave two commands' bytes and hand each caller a plausible answer built from the other's. When commands have to run at the same time, open a second connection or use one of the concurrent transports above.
Three things sit outside "calling from several threads is safe", on every transport:
-
A monitor observing a change you make yourself.
LoadListenWithCallback/ExecuteWithCallbackalongside ordinary commands never corrupts anything, but on the serialized transports the monitor's worker occupies the terminal on its own cadence, and a change made over the same connection may go unreported — measured on Telnet and WinBox CLI, a listen missed it 3 times in 4, and polling harder made it worse. Make the change over a second connection, or use the binary API, where the tag makes the two independent. -
Closing.
Close()andDispose()tear the channel down under whatever is using it, and they do not wait for a running command — deliberately, because a caller closing a connection to escape a stuck command would be defeated by a Close that blocked forReceiveTimeoutfirst. A command in flight at that moment fails with aTikConnectionNotOpenExceptionsaying the connection was closed underneath it (the underlying socket exception is kept asInnerException). What nobody can tell you is whether the router ran it — the bytes may have reached the router before the socket went, so treat an interrupted write as unknown, not as failed. Finish or cancel outstanding work first if that distinction matters. -
Safe Mode. It is connection-wide router state, not a per-command option: do not drive
SafeModeTake/SafeModeReleasefrom two threads, and remember that every command issued while it is held takes part in it.
The O/R mapper follows the connection, and its change tracking is per connection and safe for distinct entities. Saving the same entity object from two threads is not — for the ordinary reason that two threads are then editing one object.
TikConnectionSetup is an options object with no synchronization of its own: fill it in on one thread,
then create as many connections from it as you like. Registering a satellite-package transport
(ConnectionFactory.RegisterConnectionFactory) is thread-safe.
Threads make the client faster; they do not make the router faster, and past a point they make it slower. Measured on a lab router (RouterOS 7.23.2, virtualized), a burst of a couple of hundred commands runs at about a 1 ms round trip and then hits a wall: round trips snap to 20 ms and worse and stay there for as long as the load continues. This is the router's behaviour, not the library's — it happens on the binary API and on WinBox alike, over TCP and over the MAC layer.
The important part is that the ceiling is aggregate, not per connection. Driving the same work over more connections does not get more through; it reaches the wall sooner and then the connections queue behind each other:
| connections | commands before the wall (each) | combined |
|---|---|---|
| 1 | 216 | 216 |
| 2 | ~66 | ~133 |
| 4 | 31 | 124 |
So the thing to do is pace bulk work, not parallelize it. The budget refills once the load stops — a short pause every so often keeps you in the fast regime, whereas opening a second connection to go faster achieves the opposite.
On TCP transports the slowdown is lumpy rather than smooth (one delayed reply holds up the replies behind it), so a busy connection can go quiet for seconds at a time. Give commands a timeout that can sit through that; a request that times out under this load has usually not been lost, just delayed.
How hard the ceiling bites will depend on your router and, if it is virtualized, on the resources it has been given — the numbers above are one lab router's, not a specification.
Both halves of the contract are covered by the integration suite, on every transport:
- the concurrent transports must hand each caller its own reply when six threads issue five rounds of three differently-shaped commands at once — a mis-delivered reply shows up as a wrong value, not as a plausible-looking result;
- the serialized ones run the same body and must neither deadlock nor lose a command, because a terminal that let two commands interleave would answer each caller from the other's output rather than fail.
Every transport runs exactly one of the two, so the two lists cannot quietly drift apart.
- Connection types and capabilities — the capability matrix
- Exception handling — what a torn-down connection throws
- Safe Mode
Start here
- Getting started — first project
- Which API level?
- One task, every transport & level — 29 runnable programs
- CRUD examples, all levels
- Upgrading from 3.x · with an AI agent
- Upgrading from 4.x to 5.0 · with an AI agent — unreleased
API levels
-
High-level — O/R mapper
- Reading data
- CRUD · async CRUD
- Advanced — streaming, ordering
- Change tracking
- List merging
- ADO.NET-like — commands, parameters
- Low-level — raw sentences
- VB.NET example
Entities
- Entity reference — all 171 menus
- How the mapping works
- TikValue — what a loaded property holds, and why
- Custom entities
- Value types · helpers
- Scaffolding tools
Transports
- Types & capabilities — the matrix
- Threading & concurrency — sharing one connection
- API-SSL · login versions
- REST
- Telnet · SSH
- MAC-Telnet
- RoMON — through a neighbouring router
- WinBox CLI · over MAC
- WinBox native · over MAC
- Command translation
- MNDP discovery
- Writing your own transport
Safety & diagnostics
- Safe Mode — rollback protection
- Exception handling
- Communication debugging
- Testing without a router
Project
- RouterOS versions — what is tested, and how versions differ
- MCP server
- History / changelog