---
title: "Temporal Signals for Automated Coordination: Locking, Blocking, and Cross-Workflow Communication"
description: "Build distributed mutexes, cross-workflow coordination, and resource locking patterns using Temporal signals for automated workflow-to-workflow communication."
url: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-signals-automated/
section: knowledge
date: 2026-02-22
categories: ["workflow-orchestration"]
tags: ["temporal","signals","distributed-mutex","locking","cross-workflow","coordination","automated-signals"]
skills: ["distributed-mutex-design","cross-workflow-signaling","resource-coordination","signal-based-locking"]
tools: ["temporal","go"]
levels: ["intermediate"]
word_count: 1710
formats:
  json: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-signals-automated/index.json
  html: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-signals-automated/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Temporal+Signals+for+Automated+Coordination%3A+Locking%2C+Blocking%2C+and+Cross-Workflow+Communication
---


# Temporal Signals for Automated Coordination

In [Temporal Signals for Manual Interaction](../temporal-signals-manual/), 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.

This article covers automated signal patterns: cross-workflow signaling, distributed mutexes built on signals, blocking semantics, and the anti-patterns that will burn you.

All code examples reference the companion repository at [github.com/statherm/temporal-examples](https://github.com/statherm/temporal-examples) in the `signals-automated/` directory.

## Signals Beyond Human Input

When one workflow needs to coordinate with another -- waiting for a resource, synchronizing execution order, or passing state -- signals provide a durable, replay-safe mechanism. Unlike activities that call external services, signals are internal to Temporal and carry its durability guarantees.

Automated signal use cases include:

- **Resource 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.

## Cross-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.

```go
// Sender: signal another workflow
func CoordinatorWorkflow(ctx workflow.Context, targetWorkflowID string) error {
    payload := CoordinationPayload{
        SourceWorkflowID: workflow.GetInfo(ctx).WorkflowExecution.ID,
        Action:           "proceed",
        Timestamp:        workflow.Now(ctx),
    }

    future := workflow.SignalExternalWorkflow(ctx, targetWorkflowID, "", "coordination-signal", payload)
    err := future.Get(ctx, nil)
    if err != nil {
        // Target workflow does not exist or is completed
        return fmt.Errorf("failed to signal target workflow: %w", err)
    }
    return nil
}

// Receiver: wait for signal from another workflow
func WorkerWorkflow(ctx workflow.Context) error {
    ch := workflow.GetSignalChannel(ctx, "coordination-signal")

    var payload CoordinationPayload
    ch.Receive(ctx, &payload)

    logger := workflow.GetLogger(ctx)
    logger.Info("Received coordination signal",
        "source", payload.SourceWorkflowID,
        "action", 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 -- the sender does not block waiting for the receiver to process it.

Common use cases for cross-workflow signaling:

| Pattern | 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 "mutex workflow" manages exclusive access to a resource. Other workflows request and release locks by signaling this mutex workflow.

### The 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.

```go
type 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 := ""

    lockCh := workflow.GetSignalChannel(ctx, "lock-request")
    unlockCh := workflow.GetSignalChannel(ctx, "unlock-request")

    for {
        selector := workflow.NewSelector(ctx)

        selector.AddReceive(lockCh, func(c workflow.ReceiveChannel, more bool) {
            var req LockRequest
            c.Receive(ctx, &req)
            logger.Info("Lock requested",
                "resource", resourceID,
                "requester", req.RequesterWorkflowID)

            if !locked {
                locked = true
                currentHolder = req.RequesterWorkflowID
                // Signal back that lock is acquired
                workflow.SignalExternalWorkflow(
                    ctx,
                    req.RequesterWorkflowID,
                    req.RequesterRunID,
                    "lock-acquired",
                    nil,
                )
                logger.Info("Lock granted",
                    "resource", resourceID,
                    "holder", currentHolder)
            } else {
                queue = append(queue, req)
                logger.Info("Lock queued",
                    "resource", resourceID,
                    "requester", req.RequesterWorkflowID,
                    "queueDepth", len(queue))
            }
        })

        selector.AddReceive(unlockCh, func(c workflow.ReceiveChannel, more bool) {
            var req UnlockRequest
            c.Receive(ctx, &req)

            if req.RequesterWorkflowID != currentHolder {
                logger.Warn("Unlock from non-holder ignored",
                    "resource", resourceID,
                    "requester", req.RequesterWorkflowID,
                    "holder", currentHolder)
                return
            }

            logger.Info("Lock released",
                "resource", resourceID,
                "holder", currentHolder)

            if len(queue) > 0 {
                next := queue[0]
                queue = queue[1:]
                currentHolder = next.RequesterWorkflowID
                workflow.SignalExternalWorkflow(
                    ctx,
                    next.RequesterWorkflowID,
                    next.RequesterRunID,
                    "lock-acquired",
                    nil,
                )
                logger.Info("Lock granted to next in queue",
                    "resource", resourceID,
                    "holder", currentHolder)
            } else {
                locked = false
                currentHolder = ""
            }
        })

        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-<resourceID>`.

### Using the Mutex

Client workflows interact with the mutex through helper functions that encapsulate the signal handshake.

```go
func AcquireLock(ctx workflow.Context, resourceID string, timeout time.Duration) error {
    mutexWorkflowID := fmt.Sprintf("mutex-%s", 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, "", "lock-request", req)
    if err := future.Get(ctx, nil); err != nil {
        return fmt.Errorf("mutex workflow not found for resource %s: %w", resourceID, err)
    }

    // Wait for lock-acquired signal with timeout
    lockCh := workflow.GetSignalChannel(ctx, "lock-acquired")
    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 > 0 {
        selector.AddFuture(workflow.NewTimer(timerCtx, timeout), func(f workflow.Future) {
            // Timer fired before lock acquired
        })
    }

    selector.Select(ctx)

    if !lockAcquired {
        return fmt.Errorf("lock acquisition timed out for resource %s", resourceID)
    }
    return nil
}

func ReleaseLock(ctx workflow.Context, resourceID string) error {
    mutexWorkflowID := fmt.Sprintf("mutex-%s", resourceID)

    req := UnlockRequest{
        RequesterWorkflowID: workflow.GetInfo(ctx).WorkflowExecution.ID,
    }

    future := workflow.SignalExternalWorkflow(ctx, mutexWorkflowID, "", "unlock-request", req)
    return future.Get(ctx, nil)
}
```

A workflow that needs exclusive access to a resource uses these helpers:

```go
func 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("could not acquire lock: %w", 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, &result)
    if err != nil {
        return err
    }

    return workflow.ExecuteActivity(ctx, NotifyCompletion, result).Get(ctx, nil)
}
```

## Blocking Semantics

When a workflow calls `ch.Receive(ctx, &payload)` on a signal channel, it blocks durably. The workflow is not consuming CPU or memory while waiting. Temporal persists the workflow's state, and the worker can process other tasks. When the signal arrives, Temporal dispatches the workflow back to a worker for continued execution.

This 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 -- the state is in the event history.

This 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's persistence layer.

### Timeout 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.

```go
func 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, &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("signal %s not received within %v", signalName, timeout)
    }
    return result, nil
}
```

## Testing the Mutex

Use the Temporal test framework to verify the mutex under different conditions.

```go
func 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, "resource-1")

    // 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("lock-request", LockRequest{
            RequesterWorkflowID: "workflow-A",
            RequesterRunID:      "run-A",
        })
    }, time.Millisecond)

    env.RegisterDelayedCallback(func() {
        // Second requester signals while first holds lock
        env.SignalWorkflow("lock-request", LockRequest{
            RequesterWorkflowID: "workflow-B",
            RequesterRunID:      "run-B",
        })
    }, 2*time.Millisecond)

    env.RegisterDelayedCallback(func() {
        // First requester releases
        env.SignalWorkflow("unlock-request", UnlockRequest{
            RequesterWorkflowID: "workflow-A",
        })
    }, 3*time.Millisecond)

    env.ExecuteWorkflow(MutexWorkflow, "resource-1")
}
```

See the full test suite in `signals-automated/mutex_test.go` in the companion repository.

## Anti-Patterns

Signals are a powerful primitive, but they can create hard-to-debug problems when misused.

### Do 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.

### Do 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.

### Do 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.

### Watch Signal Payload Size

Signal payloads are stored in workflow history. Large payloads (megabytes) inflate history size and slow replays. Keep signal payloads small -- identifiers and metadata, not full data blobs. Pass large data through an external store and include only a reference in the signal.

### Always 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 -- retry, compensate, or alert.

## Alternative: Activity-Based Coordination

Signals are not always the right coordination mechanism. Consider alternatives when:

| Situation | 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.

## Next Steps

With automated signal coordination in place, you can build complex multi-workflow systems. The [Multi-Stage Workflows](../temporal-multi-stage-workflows/) article shows how to chain workflow stages together, and the [Cross-Cluster Communication](../temporal-cross-cluster-communication/) article extends these patterns across independent Temporal deployments.

