Python Multiprocessing Queue: Usage and Pitfalls
Learn how Python's multiprocessing.Queue works, how to use it safely for inter-process communication, and how to avoid deadlocks, data loss, and performance pitfalls.
Python's multiprocessing.Queue provides a thread- and process-safe FIFO channel for passing picklable Python objects between processes. It is the standard tool when multiple Python processes need to exchange data. This article explains how the queue works, how to use it correctly, and what pitfalls to avoid in real applications.
How multiprocessing.Queue Works
The multiprocessing.Queue class is built on top of a pipe and a lock. It serializes objects using pickle, writes them to the pipe, and reads them on the other end. Because the pipe is protected by a lock, concurrent put and get calls from different processes are safe. The queue also has an internal buffer and uses a feeder thread to manage writes, which allows a producer to continue while the consumer is still processing.
This design means that the queue is not a simple shared memory region. Every object you put into it is pickled, transmitted through the pipe, and unpickled on the receiving side. That serialization step is the main performance cost, and it also imposes a restriction: objects that cannot be pickled—such as lambdas, locally defined functions, or open sockets—cannot be passed through a queue.
Creating a Queue and Passing It to Processes
The simplest usage is to create a Queue in the parent process and pass it as an argument to child processes. Here is a minimal example:
from multiprocessing import Process, Queue def worker(q): q.put('hello from worker') if __name__ == '__main__': q = Queue() p = Process(target=worker, args=(q,)) p.start() print(q.get()) # prints: hello from worker p.join()
The queue must be passed as an argument because child processes inherit the parent's memory space at fork time, but a Queue object uses a background thread and a file descriptor that must be explicitly shared. When you pass it as an argument, the child gets a reference to the same underlying pipe. On Windows, where processes are spawned rather than forked, this is even more critical because the queue must be pickled and sent to the child.
Queue vs Pipe vs Shared Memory
multiprocessing.Queue is not the only way to share data. A Pipe is faster for simple two-way communication, but it is not safe for multiple readers or writers. Shared memory (Value or Array) is useful for numeric data but requires manual synchronization. The table below summarizes the tradeoffs:
| Approach | Use case | Concurrency | Overhead |
|---|---|---|---|
| Queue | Many producers/consumers | Safe | Moderate (pickling) |
| Pipe | Two processes | Not safe for >2 | Low |
| Shared memory | Numeric arrays | Needs Lock | Low |
Choose a Queue when you need a simple, safe message channel between multiple processes. For high-throughput numeric data, shared memory with a lock is often faster because it avoids serialization. For a one-to-one communication pattern, a Pipe may be sufficient and lighter.
Common Pitfalls: Deadlocks, Blocking, and Data Loss
A frequent mistake is calling q.get() without a timeout or sentinel when the queue is empty. That call blocks until a producer puts an item, so if no item ever arrives, the process hangs. Use a timeout, or design the producer to send a sentinel value such as None when it is done. q.empty() is not reliable for synchronization because another process can put or get items at any time.
A classic deadlock with multiprocessing.Queue happens when child processes have put items on a queue and you try to join them before those items have been consumed. The child process waits to flush its remaining buffered items to the pipe, while the parent waits for the child to exit. A related version occurs when a producer blocks on q.put() to a full queue while the parent is waiting for that producer to finish instead of draining the queue. For JoinableQueue, call task_done() after each get() and call join() only when you need to wait until all previously put items have been processed. Forgetting task_done() causes join() to hang, which is a common source of confusion.
Data loss can occur if a process is terminated while it still holds items in its local buffer. The queue's feeder thread may not flush pending items if the process is killed abruptly. Signal producers to stop, drain the queue, and then join the processes. Call q.close() and q.join_thread() when you need to ensure the feeder thread has written all buffered items.
Performance Considerations: Throughput and Latency
The main performance cost of a multiprocessing.Queue is pickling. Every object you put into the queue is serialized and deserialized, which adds CPU overhead and memory allocation. For large objects, this can dominate the cost of inter-process communication. To reduce overhead, send small messages or batch multiple items into a single list. Also, avoid sending the same large object repeatedly; consider using shared memory if the data is a NumPy array or a large bytes buffer.
The queue's internal buffer size is controlled by the maxsize parameter. A small maxsize limits memory usage but increases blocking. A large maxsize reduces blocking but can consume a lot of memory if the producer outpaces the consumer. Monitor your application's memory and adjust accordingly. In practice, a maxsize of a few thousand is often a good balance for typical workloads, but the optimal value depends on your data size and production rate.
Using Queue with multiprocessing.Pool
When you use multiprocessing.Pool, you don't need to create a queue manually. The pool's map and imap methods handle task distribution internally. However, if you need to collect results from worker processes, you can pass a queue as an argument to the worker function, but you must be careful: the pool's processes are reused, so the queue must be created in the parent and passed via initializer or as an argument. Here is an example:
from multiprocessing import Pool, Queue def init(q): global queue queue = q def worker(x): queue.put(x * 2) if __name__ == '__main__': q = Queue() pool = Pool(4, initializer=init, initargs=(q,)) pool.map(worker, range(10)) pool.close() pool.join() while not q.empty(): print(q.get())
This pattern is usually unnecessary: Pool.map returns results in input order, so collecting those directly avoids a shared queue. If you do write to a shared queue from pool workers, be aware that item order is not guaranteed and that the queue must be drained only after workers have finished normally. Call pool.close() and pool.join() before draining; the with Pool context manager terminates workers on exit and can lose buffered queue items.
Handling Exceptions and Timeouts
When a worker process crashes, the queue may become corrupted. Always wrap get() in a try-except block to handle EOFError, which indicates that the producer has closed the queue. Also, use put with a timeout to avoid blocking forever if the consumer is slow. Example:
import queue try: item = q.get(timeout=5) except queue.Empty: print('No item received') except EOFError: print('Producer closed the queue')
When using put, catch queue.Full if you specify a timeout. This is especially important in producer-consumer setups where the consumer may be slower than the producer. A common pattern is to log the timeout and retry or drop the item, depending on your application's requirements.
Production Considerations: Resource Cleanup and Termination
In a long-running application, you must ensure that queues are closed and processes are joined to avoid resource leaks. Call q.close() when you are done putting items, and q.join_thread() to wait for the feeder thread to flush pending items. Also, always call p.join() on child processes to prevent zombie processes. If a process is stuck, you may need to terminate it, but be aware that terminating a process that is blocked on a queue can leave the queue in an inconsistent state.
For example, if you terminate a producer that has already put items into the queue but has not yet flushed its feeder thread, those items may be lost. The safest approach is to design your protocol so that producers signal completion explicitly, for example by sending a sentinel value like None, and consumers know to stop when they receive it. This avoids relying on process termination to signal the end of data.
Another production concern is the number of file descriptors. Each multiprocessing.Queue uses a pipe, which consumes two file descriptors. If you create many queues, you can exhaust the file descriptor limit. Reuse queues when possible, and close them when they are no longer needed. In a long-lived service, monitor file descriptor usage to avoid unexpected failures.