Memra

Getting a result back out

◈ 5 cards

Why a plain getter on a worker thread races, and the four fixes ranked: polling, callback, join(), Future — with the visibility rule that makes two of them work.

The accessor that races

The obvious way to get an answer out of a thread is to have run() store it in a field and give the class a getter. Then main() starts the thread and calls the getter. It reads null.

Nothing is broken, exactly. start() returns immediately — that is its entire job — so main() reaches the getter while the worker is still inside a DNS round trip that will not finish for another 40 ms. The field has never been written. Whether you see a correct answer, a null, or a NullPointerException depends on the speed of the two threads relative to each other, and you control neither. That is a race condition: a program whose correctness depends on timing you cannot specify.

Moving the read further down main() — start every thread in one loop, read every result in a second loop — makes the failure rarer, which is worse. It now passes on your laptop and fails on the marker's four-core machine, or on the tenth run, or only when the network is slow.

Fix 1 — polling (do not)

Spin on the getter until it stops returning null. This is where most people start, and it has two independent defects. It burns a whole core doing nothing, and on some schedulers the spinning thread is so busy asking that the worker never gets scheduled to answer — the program hangs while looking maximally busy. Worse, without volatile on the field there is no guarantee the spinning thread will ever observe the write; the JVM is free to hoist the read out of the loop.

Fix 2 — the callback

Invert the direction. Instead of the consumer asking repeatedly, the worker calls a method on a listener object as its last act in run(). Nothing spins, and the result is delivered exactly once at exactly the right moment. The cost is control flow: the code that handles the result now lives in a different object, on the worker's thread, and if several workers call the same listener you are back to the shared-state problem of L3.3.

Fix 3 — join()

t.join() blocks the caller until t has finished. Afterwards the result field is guaranteed both written and visible — a successful join() establishes a happens-before edge, so no volatile is needed for a field written before run() returned. It is correct, it is two lines, and it is the right answer for a fixed handful of tasks.

Its one trap is placement. t.start(); t.join(); back to back is a sequential program with extra ceremony: you created a thread and immediately waited for it. Start all the threads, then join them.

Worked example — one lookup, four ways

Take the resolver from L3.1 and give it a String address field plus getAddress(). Called straight after start(), the getter returns null — the read at t2 lands before the write at t3. Polling on it works but pins a core. A callback works and moves the printing into the worker. join() works, keeps the printing in main(), and costs one line — provided you join after the loop that starts everything, not inside it.

All four are stepping stones. The version you will actually ship submits a Callable and holds a Future (L3.5), which is the callback and the join() packaged together with the return value and the exception handling.

t1t2t3mainstart()getAddress() -> nullworkerDNS waitstoreThe read at t2 happens before the write at t3.
The bug is not in either thread — each is correct on its own. It is in the assumption that start() has finished the work by the time the next statement runs. start() only starts.
NORMAL ~/memra/learn/comp-348/results-from-a-thread utf-8 LF