openskills.info

Concurrent Programming

itSoftware engineering

Concurrent Programming

Concurrent programming lets a program make progress on more than one task during the same period. The tasks may alternate on one processor core or run at the same instant on several cores. That distinction matters: concurrency is about structure, while parallelism is about simultaneous execution.

You use concurrency whenever work can overlap. A server handles one request while another waits for storage. A desktop application keeps its interface responsive during a download. A data pipeline processes independent partitions at once. The goal may be responsiveness, throughput, resource use, or a clear model for many activities in flight.

Concurrency also removes a useful assumption. In sequential code, one operation finishes before the next begins. In concurrent code, a scheduler may interleave operations in many valid orders. Your design must remain correct across every allowed order, not only the order you happened to observe in a test.

The execution building blocks

A process is a running program with its own resources and address space. A thread is a flow of control inside a process. Threads in one process normally share memory. A task is a unit of work submitted to a runtime or executor; the runtime decides which thread or process runs it. A coroutine is a function that can suspend and later resume, often on the same thread as other coroutines.

These abstractions solve different problems:

  • Threads support preemptive concurrency and shared memory.
  • Processes isolate memory and failures more strongly, but communication costs more.
  • Tasks separate the work you want done from the workers that execute it.
  • Coroutines make large numbers of waiting operations practical through cooperative suspension.

Choose the highest-level abstraction that expresses the work. A task pool usually communicates intent better than creating and managing one thread per operation.

Shared state creates the central risk

Two tasks conflict when they access the same mutable state and at least one access writes it. A data race occurs when conflicting accesses lack the ordering required by the language's memory model. A race condition is broader: the result incorrectly depends on timing or ordering. A program can have a race condition without a low-level data race, such as two correctly locked operations combined in the wrong order.

Even a short update can contain several steps. Reading a counter, adding one, and writing the result is a read-modify-write operation. Two tasks can read the same old value and then overwrite each other's updates. This is a lost update.

Visibility matters too. A write performed by one thread is not automatically guaranteed to become visible to another thread in the order you expect. Languages define a happens-before relationship that states when one action is ordered before and visible to another. Locks, channel operations, thread start and join, and atomic operations can establish this relationship under their documented rules.

Do not treat a passing test or a particular processor's behavior as a memory-order guarantee. Use the synchronization operations defined by your language and runtime.

Three ways to control interaction

1. Avoid sharing mutable state

Immutable values can be read safely by many tasks. Partitioned state gives each task exclusive ownership of a separate region. Thread confinement keeps mutable state inside one thread. These designs remove conflicts instead of coordinating them.

2. Communicate by message passing

Tasks send values through a channel, queue, or mailbox. Ownership may transfer with the message, or the message may contain an immutable copy. Message passing makes communication points visible and can reduce shared-state reasoning. It still needs a policy for queue capacity, shutdown, errors, and slow consumers.

3. Synchronize shared state

A mutex gives one task at a time access to a critical section. A read-write lock permits multiple readers or one writer. A semaphore tracks a nonnegative count and can limit access to a finite resource. A condition variable lets a task wait for a state predicate while releasing and later reacquiring a lock. An atomic operation performs a supported update as one indivisible memory operation with defined ordering.

Keep critical sections small, but keep the whole invariant protected. Splitting one logical update across separately locked sections can preserve individual operations while breaking the overall rule.

Coordination above individual locks

A future represents a result that may become available later. An executor accepts tasks and maps them onto workers. A bounded worker pool limits active work and prevents unbounded thread creation. A bounded queue adds backpressure by making producers slow down or reject work when consumers cannot keep up.

Structured concurrency keeps related child tasks inside a lexical scope. The parent waits for them, propagates failure, and coordinates cancellation before leaving the scope. This makes task lifetime and cleanup easier to see than detached background tasks.

Cancellation is a protocol, not instant task destruction. A task needs cancellation points and cleanup logic. Timeouts should flow through the whole operation so abandoned work does not continue consuming resources after its caller has stopped waiting.

Safety and liveness

Safety means nothing incorrect happens. Examples include preserving an account balance invariant and preventing two tasks from mutating a non-thread-safe object together.

Liveness means useful work eventually happens. Common liveness failures include:

  • Deadlock: tasks wait forever for one another.
  • Starvation: a ready task repeatedly loses access to a needed resource.
  • Livelock: tasks keep reacting to one another but make no useful progress.

A consistent global lock order prevents circular lock acquisition. Avoid holding a lock while calling unknown code, waiting for input or output, or blocking on another task. Those actions expand the dependency graph beyond the code you can easily inspect.

Performance is a measurement problem

Concurrency adds scheduling, coordination, memory traffic, and context-switch costs. More workers can reduce performance when they compete for a lock, exhaust a connection pool, or move the same cache line between cores. CPU-bound work can benefit from parallel workers up to the useful hardware and workload limit. Waiting-heavy work can support more concurrent tasks because most tasks are not consuming a core.

Start from correctness. Then measure throughput, latency percentiles, queue depth, utilization, lock contention, and time spent waiting. Tune worker counts and queue bounds against the real bottleneck. A faster incorrect program is still incorrect.

A practical design sequence

  1. Identify independent units of work and the reason they should overlap.
  2. List every mutable value that crosses a task boundary.
  3. Prefer immutability, ownership, or partitioning before shared mutation.
  4. Define task lifetime, cancellation, error propagation, and overload behavior.
  5. Protect each remaining invariant with one clear synchronization policy.
  6. Test under repeated schedules and use the runtime's race and deadlock tools.
  7. Measure before changing pool sizes, lock granularity, or memory ordering.

Concurrent programming is useful when overlap matches the problem. It is a poor choice when tasks are inherently sequential, the workload is too small to repay coordination costs, or the team cannot state the ownership and shutdown rules clearly.