What’s New in Swift 6.4?

Learn what’s new in Swift 6.4, including syntax and ergonomics improvements, concurrency updates like async defer and task cancellation shields, and updates to Swift’s libraries and build system. By Mani Ramezan.

Leave a rating/review
Download materials
Save for later
How this was made
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Concurrency

Async defer

Since Swift 2, Swift has supported the defer statement, which lets you schedule code to run when the current scope exits. This helps ensure cleanup code runs even if a function returns early or throws. But defer has always run synchronously. Often, cleanup code is simple and doesn’t need to await anything, so synchronous defer works fine — for example, to close a file handle or a database connection:

func processTransaction(id: String) async throws {
  let session = try await databaseManager.openSession(for: id)
  
  // ❌ error: 'async' call cannot occur in a defer body
  defer {
    await session.close()
  }
  
  try await session.writeLogs()
  try await session.commit()
}

As a workaround, you could spin up a Task inside the defer block:

func processTransaction(id: String) async throws {
  ...
  defer {
    Task {
      await session.close()
    }
  }
  ...
}

Looks correct, right? This works for the most part, but on closer inspection it trades one problem for another: now it’s unstructured, and processTransaction can return before session.close() ever finishes. There’s no guarantee the cleanup happens before the enclosing function returns.

Swift 6.4 closes this gap by letting you await inside a regular defer block, so you can now write:

func processTransaction(id: String) async throws {
  ...
  defer {
    await session.close()
  }
  ...
}

Now the function genuinely waits for session.close() before it returns — no stray background task, no race.

Explicit non-Sendable

As part of Swift concurrency, Swift added Sendable to specify types that can be safely transferred across concurrency domains. This helps the compiler enforce safe usage of types in concurrent contexts. However, there are cases where you want to explicitly mark a type as non-Sendable, indicating that it’s not safe to transfer across concurrency domains. Also, when the developer does not mark a type as Sendable, there is some ambiguity: is that intentional, or is the conformance simply overlooked?

One older workaround was to add an unavailable Sendable conformance. This does not make the type Sendable; it documents that Sendable conformance is intentionally unavailable:

class BaseEventBuffer {
  private var events: [String] = []

  func add(event: String) {
    events.append(event)
  }

  func flush() -> [String] {
    let currentEvents = events
    events.removeAll()
    return currentEvents
  }
}

@available(*, unavailable, message: "BaseEventBuffer has unsynchronized mutable state")
extension BaseEventBuffer: @unchecked Sendable {}

However, this workaround has limitations. For example, let’s say you now want to subclass this class and create a thread-safe buffer using locks:

// Subclass guarantees atomic additions using an unfair lock.
final class LockedEventBuffer: BaseEventBuffer, @unchecked Sendable {
  private let lock = OSAllocatedUnfairLock()

  override func add(event: String) {
    lock.withLock {
      MainActor.assumeIsolated {
        super.add(event: event)
      }
    }
  }

  override func flush() -> [String] {
    lock.withLock {
      MainActor.assumeIsolated {
        super.flush()
      }
    }
  }
}

Now when you try to compile, you’ll see the following warning:

Screenshot showing warning: Conformance of 'SendableClass' to protocol 'Sendable' is already unavailable

To help with this limitation, Swift 6.4 introduces ~Sendable. This allows you to explicitly mark a class as non-Sendable without relying on the availability attribute hack:

class BaseEventBuffer: ~Sendable { ... }

final class LockedEventBuffer: BaseEventBuffer, @unchecked Sendable { ... }

BaseEventBuffer now explicitly identifies itself as not thread-safe, while LockedEventBuffer is thread-safe. This lets you mark your types as non-Sendable directly, without the limitations of the unavailable-conformance workaround.

Task cancellation shields

As part of modern concurrency, you can spin off asynchronous tasks and cancel them when they’re no longer needed. Consider a case where you start downloading a file and cache it locally:

class DiskCache {
  func deleteTempFile(at path: String) {
    guard !Task.isCancelled else { return }
    try? FileManager.default.removeItem(atPath: path)
  }
}

class ImageDownloader {
  let cache = DiskCache()

  func downloadImage(from url: URL) async throws {
    let tempPath = URL.temporaryDirectory.appending(path: url.lastPathComponent).path()

    do {
      try await networkClient.download(from: url, to: tempPath)
    } catch {
      cache.deleteTempFile(at: tempPath)
      throw error
    }
  }
}

You’d probably use it in your app like this:

struct ContentView: View {
  @State private var isDownloading = false
  private let downloader = ImageDownloader()
  // 1
  @State private var currTask: Task<(), Never>?
  var body: some View {
    // 2
    Button {
      // 3
      if isDownloading {
          currTask?.cancel()
      } else {
        // 4
        currTask = Task {
          isDownloading = true
          // 5
          defer { isDownloading = false }
          do {
            try await downloader.downloadImage(from: URL(string: "www.google.com/file.png")!)
          } catch {
            // handle error
          }
        }
      }
    } label: {
      Text(isDownloading ? "Cancel" : "Download")
    }
  }
}

This is a simplified example of what you might have in production code:

  1. currTask is a local task property that lets you cancel the download later.
  2. A button the user taps to start a download or cancel one.
  3. If a download is already in progress, cancel it; otherwise, start a new one.
  4. Store the new task so it can be canceled later.
  5. Always reset isDownloading back to false when the download finishes, fails, or is cancelled.

Now, this might look fine at first glance, but it has a subtle issue related to task cancellation. Can you spot what could go wrong here? 🤔

The issue is hidden inside deleteTempFile(at:), the first line:

guard !Task.isCancelled else { return }

Here’s what happens:

  • User taps on the cancel button which cancels the task
  • Task.isCancelled is now set to true
  • The catch block in downloadImage(from:) is called with CancellationError
  • And then when deleteTempFile(at:) is called, it returns early before cleaning up the file

To fix this before, you had to spin off an unstructured task to perform the cleanup:

Task {
  cache.deleteTempFile(at: tempPath)
}

This operates as a workaround but it introduces complexity and potential race conditions. The cleanup task is now running independently of the original task’s lifecycle. Swift 6.4 gives you a better way to handle this with task cancellation shields. This feature allows you to protect critical sections of code from premature cancellation without resorting to unstructured tasks. Use the withTaskCancellationShield function to wrap the critical section:

withTaskCancellationShield {
  cache.deleteTempFile(at: tempPath)
}

The new withTaskCancellationShield function ensures that the enclosed code block runs to completion even if something cancels the surrounding task. This uses no unstructured tasks, and doesn’t generate any race conditions. Inside the shielded block, Task.isCancelled always returns false, so the critical section runs uninterrupted.

How this was made

Our AI policy
Words
Written by the author, AI-edited
Code
AI-assisted, author-verified
Media
  • Illustrations: Illustrator