{"page":{"agent_metadata":{"content_type":"guide","outputs":["implement-distributed-mutex","coordinate-workflows-via-signals","design-resource-locking-patterns","avoid-signal-anti-patterns"],"prerequisites":["temporal-signals-manual","temporal-multi-stage-workflows"]},"categories":["workflow-orchestration"],"content_plain":"Temporal Signals for Automated Coordination# In Temporal Signals for Manual Interaction, you learned how external systems and humans send signals to running workflows. Signals are not limited to human input. They are a general-purpose communication channel between workflows, and they become powerful coordination primitives when workflows signal each other programmatically.\nThis article covers automated signal patterns: cross-workflow signaling, distributed mutexes built on signals, blocking semantics, and the anti-patterns that will burn you.\nAll code examples reference the companion repository at github.com/statherm/temporal-examples in the signals-automated/ directory.\nSignals Beyond Human Input# When one workflow needs to coordinate with another \u0026ndash; waiting for a resource, synchronizing execution order, or passing state \u0026ndash; signals provide a durable, replay-safe mechanism. Unlike activities that call external services, signals are internal to Temporal and carry its durability guarantees.\nAutomated signal use cases include:\nResource locking: A mutex workflow manages exclusive access to a shared resource Pipeline coordination: Stage N signals Stage N+1 that data is ready Fan-out/fan-in: A parent workflow signals child workflows to start, children signal back when done Circuit breaking: A monitor workflow signals dependent workflows to pause or resume The key insight is that workflow.SignalExternalWorkflow lets any workflow send a signal to any other workflow by ID. This creates a bidirectional communication channel between independently running workflows.\nCross-Workflow Signaling# To signal another workflow, you need its workflow ID (and optionally its run ID). The sending workflow calls SignalExternalWorkflow, and the receiving workflow picks up the signal on a channel.\n// Sender: signal another workflow func CoordinatorWorkflow(ctx workflow.Context, targetWorkflowID string) error { payload := CoordinationPayload{ SourceWorkflowID: workflow.GetInfo(ctx).WorkflowExecution.ID, Action: \u0026#34;proceed\u0026#34;, Timestamp: workflow.Now(ctx), } future := workflow.SignalExternalWorkflow(ctx, targetWorkflowID, \u0026#34;\u0026#34;, \u0026#34;coordination-signal\u0026#34;, payload) err := future.Get(ctx, nil) if err != nil { // Target workflow does not exist or is completed return fmt.Errorf(\u0026#34;failed to signal target workflow: %w\u0026#34;, err) } return nil } // Receiver: wait for signal from another workflow func WorkerWorkflow(ctx workflow.Context) error { ch := workflow.GetSignalChannel(ctx, \u0026#34;coordination-signal\u0026#34;) var payload CoordinationPayload ch.Receive(ctx, \u0026amp;payload) logger := workflow.GetLogger(ctx) logger.Info(\u0026#34;Received coordination signal\u0026#34;, \u0026#34;source\u0026#34;, payload.SourceWorkflowID, \u0026#34;action\u0026#34;, payload.Action) // Proceed with work return executeWork(ctx, payload) }The SignalExternalWorkflow call returns a future. If the target workflow does not exist or has already completed, the future resolves with an error. The signal itself is delivered asynchronously \u0026ndash; the sender does not block waiting for the receiver to process it.\nCommon use cases for cross-workflow signaling:\nPattern Description Notification Workflow A tells Workflow B something happened Request/Response Workflow A signals B with a request, B signals A back with a result State sharing Workflow A sends its current state to Workflow B Lifecycle events Parent signals children to cancel or clean up Distributed Mutex via Signals# The most powerful automated signal pattern is the distributed mutex. A long-running \u0026ldquo;mutex workflow\u0026rdquo; manages exclusive access to a resource. Other workflows request and release locks by signaling this mutex workflow.\nThe Mutex Workflow# The mutex workflow runs indefinitely (or until the resource is decommissioned). It maintains an internal queue of lock requesters and grants the lock to one workflow at a time.\ntype LockRequest struct { RequesterWorkflowID string RequesterRunID string Timeout time.Duration } type UnlockRequest struct { RequesterWorkflowID string } func MutexWorkflow(ctx workflow.Context, resourceID string) error { logger := workflow.GetLogger(ctx) var queue []LockRequest locked := false currentHolder := \u0026#34;\u0026#34; lockCh := workflow.GetSignalChannel(ctx, \u0026#34;lock-request\u0026#34;) unlockCh := workflow.GetSignalChannel(ctx, \u0026#34;unlock-request\u0026#34;) for { selector := workflow.NewSelector(ctx) selector.AddReceive(lockCh, func(c workflow.ReceiveChannel, more bool) { var req LockRequest c.Receive(ctx, \u0026amp;req) logger.Info(\u0026#34;Lock requested\u0026#34;, \u0026#34;resource\u0026#34;, resourceID, \u0026#34;requester\u0026#34;, req.RequesterWorkflowID) if !locked { locked = true currentHolder = req.RequesterWorkflowID // Signal back that lock is acquired workflow.SignalExternalWorkflow( ctx, req.RequesterWorkflowID, req.RequesterRunID, \u0026#34;lock-acquired\u0026#34;, nil, ) logger.Info(\u0026#34;Lock granted\u0026#34;, \u0026#34;resource\u0026#34;, resourceID, \u0026#34;holder\u0026#34;, currentHolder) } else { queue = append(queue, req) logger.Info(\u0026#34;Lock queued\u0026#34;, \u0026#34;resource\u0026#34;, resourceID, \u0026#34;requester\u0026#34;, req.RequesterWorkflowID, \u0026#34;queueDepth\u0026#34;, len(queue)) } }) selector.AddReceive(unlockCh, func(c workflow.ReceiveChannel, more bool) { var req UnlockRequest c.Receive(ctx, \u0026amp;req) if req.RequesterWorkflowID != currentHolder { logger.Warn(\u0026#34;Unlock from non-holder ignored\u0026#34;, \u0026#34;resource\u0026#34;, resourceID, \u0026#34;requester\u0026#34;, req.RequesterWorkflowID, \u0026#34;holder\u0026#34;, currentHolder) return } logger.Info(\u0026#34;Lock released\u0026#34;, \u0026#34;resource\u0026#34;, resourceID, \u0026#34;holder\u0026#34;, currentHolder) if len(queue) \u0026gt; 0 { next := queue[0] queue = queue[1:] currentHolder = next.RequesterWorkflowID workflow.SignalExternalWorkflow( ctx, next.RequesterWorkflowID, next.RequesterRunID, \u0026#34;lock-acquired\u0026#34;, nil, ) logger.Info(\u0026#34;Lock granted to next in queue\u0026#34;, \u0026#34;resource\u0026#34;, resourceID, \u0026#34;holder\u0026#34;, currentHolder) } else { locked = false currentHolder = \u0026#34;\u0026#34; } }) selector.Select(ctx) } }This workflow never completes on its own. It runs for the lifetime of the resource it protects. Each resource gets its own mutex workflow instance, identified by a workflow ID like mutex-\u0026lt;resourceID\u0026gt;.\nUsing the Mutex# Client workflows interact with the mutex through helper functions that encapsulate the signal handshake.\nfunc AcquireLock(ctx workflow.Context, resourceID string, timeout time.Duration) error { mutexWorkflowID := fmt.Sprintf(\u0026#34;mutex-%s\u0026#34;, resourceID) req := LockRequest{ RequesterWorkflowID: workflow.GetInfo(ctx).WorkflowExecution.ID, RequesterRunID: workflow.GetInfo(ctx).WorkflowExecution.RunID, Timeout: timeout, } // Send lock request to mutex workflow future := workflow.SignalExternalWorkflow(ctx, mutexWorkflowID, \u0026#34;\u0026#34;, \u0026#34;lock-request\u0026#34;, req) if err := future.Get(ctx, nil); err != nil { return fmt.Errorf(\u0026#34;mutex workflow not found for resource %s: %w\u0026#34;, resourceID, err) } // Wait for lock-acquired signal with timeout lockCh := workflow.GetSignalChannel(ctx, \u0026#34;lock-acquired\u0026#34;) timerCtx, cancel := workflow.WithCancel(ctx) var lockAcquired bool selector := workflow.NewSelector(ctx) selector.AddReceive(lockCh, func(c workflow.ReceiveChannel, more bool) { c.Receive(ctx, nil) lockAcquired = true cancel() }) if timeout \u0026gt; 0 { selector.AddFuture(workflow.NewTimer(timerCtx, timeout), func(f workflow.Future) { // Timer fired before lock acquired }) } selector.Select(ctx) if !lockAcquired { return fmt.Errorf(\u0026#34;lock acquisition timed out for resource %s\u0026#34;, resourceID) } return nil } func ReleaseLock(ctx workflow.Context, resourceID string) error { mutexWorkflowID := fmt.Sprintf(\u0026#34;mutex-%s\u0026#34;, resourceID) req := UnlockRequest{ RequesterWorkflowID: workflow.GetInfo(ctx).WorkflowExecution.ID, } future := workflow.SignalExternalWorkflow(ctx, mutexWorkflowID, \u0026#34;\u0026#34;, \u0026#34;unlock-request\u0026#34;, req) return future.Get(ctx, nil) }A workflow that needs exclusive access to a resource uses these helpers:\nfunc ExclusiveResourceWorkflow(ctx workflow.Context, resourceID string) error { // Acquire the lock with a 30-second timeout err := AcquireLock(ctx, resourceID, 30*time.Second) if err != nil { return fmt.Errorf(\u0026#34;could not acquire lock: %w\u0026#34;, err) } // Ensure we always release the lock defer func() { _ = ReleaseLock(ctx, resourceID) }() // Do exclusive work var result ResourceResult err = workflow.ExecuteActivity(ctx, ModifySharedResource, resourceID).Get(ctx, \u0026amp;result) if err != nil { return err } return workflow.ExecuteActivity(ctx, NotifyCompletion, result).Get(ctx, nil) }Blocking Semantics# When a workflow calls ch.Receive(ctx, \u0026amp;payload) on a signal channel, it blocks durably. The workflow is not consuming CPU or memory while waiting. Temporal persists the workflow\u0026rsquo;s state, and the worker can process other tasks. When the signal arrives, Temporal dispatches the workflow back to a worker for continued execution.\nThis is fundamentally different from blocking in a goroutine. A goroutine that blocks on a channel holds memory. A Temporal workflow that blocks on a signal channel holds nothing \u0026ndash; the state is in the event history.\nThis means you can have thousands of workflows all waiting for signals simultaneously, each consuming zero worker resources. The only cost is storage in the Temporal server\u0026rsquo;s persistence layer.\nTimeout Handling# Always add timeouts when waiting for signals in automated coordination. A bug in the signaling workflow, a crashed process, or a missing mutex workflow can leave your workflow blocked forever.\nfunc waitForSignalWithTimeout(ctx workflow.Context, signalName string, timeout time.Duration) ([]byte, error) { ch := workflow.GetSignalChannel(ctx, signalName) timerCtx, cancel := workflow.WithCancel(ctx) var result []byte var received bool selector := workflow.NewSelector(ctx) selector.AddReceive(ch, func(c workflow.ReceiveChannel, more bool) { c.Receive(ctx, \u0026amp;result) received = true cancel() }) selector.AddFuture(workflow.NewTimer(timerCtx, timeout), func(f workflow.Future) { // Timeout expired }) selector.Select(ctx) if !received { return nil, fmt.Errorf(\u0026#34;signal %s not received within %v\u0026#34;, signalName, timeout) } return result, nil }Testing the Mutex# Use the Temporal test framework to verify the mutex under different conditions.\nfunc TestMutex_SingleAcquireRelease(t *testing.T) { suite := testsuite.WorkflowTestSuite{} env := suite.NewTestWorkflowEnvironment() // Register workflows env.RegisterWorkflow(MutexWorkflow) env.RegisterWorkflow(ExclusiveResourceWorkflow) env.RegisterActivity(ModifySharedResource) env.RegisterActivity(NotifyCompletion) // Start mutex workflow env.ExecuteWorkflow(MutexWorkflow, \u0026#34;resource-1\u0026#34;) // Verify no panics and mutex processes signals correctly require.False(t, env.IsWorkflowCompleted()) // Mutex runs forever } func TestMutex_Contention(t *testing.T) { suite := testsuite.WorkflowTestSuite{} env := suite.NewTestWorkflowEnvironment() // Test that two workflows acquire the lock in order env.RegisterWorkflow(MutexWorkflow) var acquireOrder []string env.RegisterDelayedCallback(func() { // First requester signals env.SignalWorkflow(\u0026#34;lock-request\u0026#34;, LockRequest{ RequesterWorkflowID: \u0026#34;workflow-A\u0026#34;, RequesterRunID: \u0026#34;run-A\u0026#34;, }) }, time.Millisecond) env.RegisterDelayedCallback(func() { // Second requester signals while first holds lock env.SignalWorkflow(\u0026#34;lock-request\u0026#34;, LockRequest{ RequesterWorkflowID: \u0026#34;workflow-B\u0026#34;, RequesterRunID: \u0026#34;run-B\u0026#34;, }) }, 2*time.Millisecond) env.RegisterDelayedCallback(func() { // First requester releases env.SignalWorkflow(\u0026#34;unlock-request\u0026#34;, UnlockRequest{ RequesterWorkflowID: \u0026#34;workflow-A\u0026#34;, }) }, 3*time.Millisecond) env.ExecuteWorkflow(MutexWorkflow, \u0026#34;resource-1\u0026#34;) }See the full test suite in signals-automated/mutex_test.go in the companion repository.\nAnti-Patterns# Signals are a powerful primitive, but they can create hard-to-debug problems when misused.\nDo Not Use Signals for High-Throughput Data Transfer# Signals are recorded in workflow history. Every signal adds an event. If you send thousands of signals per second to a workflow, the history grows rapidly and replays become slow. For bulk data transfer, use activities that read from a queue or database instead.\nDo Not Create Circular Signal Dependencies# If Workflow A waits for a signal from Workflow B, and Workflow B waits for a signal from Workflow A, you have a deadlock. Neither workflow can make progress. Design your signal flows as directed acyclic graphs.\nDo Not Rely on Signal Ordering Across Workflows# If Workflow A sends Signal 1 to Workflow C, and Workflow B sends Signal 2 to Workflow C at nearly the same time, the order in which C receives them is not guaranteed. If ordering matters, use a single coordinator workflow or include sequence numbers in your signal payloads.\nWatch Signal Payload Size# Signal payloads are stored in workflow history. Large payloads (megabytes) inflate history size and slow replays. Keep signal payloads small \u0026ndash; identifiers and metadata, not full data blobs. Pass large data through an external store and include only a reference in the signal.\nAlways Set Timeouts on Signal Waits# A workflow waiting for a signal that never arrives is stuck forever. Every automated signal wait should have a timeout with a clear error handling path \u0026ndash; retry, compensate, or alert.\nAlternative: Activity-Based Coordination# Signals are not always the right coordination mechanism. Consider alternatives when:\nSituation Better Approach Lock must survive Temporal outages External database lock (PostgreSQL advisory locks, Redis SETNX) High contention (hundreds of waiters) External queue (Redis list, SQS) Lock state must be visible to non-Temporal systems Shared database with lock table Sub-second lock acquisition required In-process mutex or external lock service The signal-based mutex is best when you need durable, long-lived coordination between a moderate number of Temporal workflows without external dependencies.\nNext Steps# With automated signal coordination in place, you can build complex multi-workflow systems. The Multi-Stage Workflows article shows how to chain workflow stages together, and the Cross-Cluster Communication article extends these patterns across independent Temporal deployments.\n","date":"2026-02-22","description":"Build distributed mutexes, cross-workflow coordination, and resource locking patterns using Temporal signals for automated workflow-to-workflow communication.","lastmod":"2026-02-22","levels":["intermediate"],"reading_time_minutes":9,"section":"knowledge","skills":["distributed-mutex-design","cross-workflow-signaling","resource-coordination","signal-based-locking"],"tags":["temporal","signals","distributed-mutex","locking","cross-workflow","coordination","automated-signals"],"title":"Temporal Signals for Automated Coordination: Locking, Blocking, and Cross-Workflow Communication","tools":["temporal","go"],"url":"https://agent-zone.ai/knowledge/workflow-orchestration/temporal-signals-automated/","word_count":1710}}