Swift 6.2 Approachable Concurrency คืออะไร? ทำไมถึงสำคัญ
ถ้าคุณเคยผ่านช่วง Swift 5.5 ที่เปิดตัว async/await มาจนถึง Swift 6.0 ที่เปิด strict concurrency checking เป็นค่าเริ่มต้น คุณน่าจะเข้าใจความรู้สึกนี้ดี — compiler errors ที่ดูเหมือนจะไม่มีวันหมด
พูดตรงๆ นะ ข้อความ error อย่าง Sending 'value' risks causing data races หรือ Non-sendable type cannot cross actor boundary มันเป็นฝันร้ายจริงๆ หลายคน (รวมถึงผมเอง) เลยลังเลที่จะ migrate โปรเจกต์ไปยัง Swift 6 เพราะแค่คิดก็เหนื่อยแล้ว
Approachable Concurrency คือชุดการเปลี่ยนแปลงใน Swift 6.2 ที่ออกแบบมาเพื่อแก้ปัญหาตรงนี้เลย เป้าหมายหลักก็คือทำให้ระบบ concurrency ของ Swift "เข้าถึงได้ง่ายขึ้น" สำหรับนักพัฒนาทุกระดับ โดยที่ยังคงความปลอดภัย (safety) ที่เป็นหัวใจของระบบไว้ได้ครบ
แนวคิดหลักเบื้องหลังคือ "Progressive Disclosure" — การเปิดเผยความซับซ้อนทีละขั้น ไอเดียก็คือนักพัฒนาควรเขียนโค้ดที่ถูกต้องและปลอดภัยได้ตั้งแต่วันแรก โดยไม่จำเป็นต้องเข้าใจ concurrency ทั้งระบบ แล้วค่อยเรียนรู้เพิ่มเมื่อต้องการประสิทธิภาพมากขึ้น
การเปลี่ยนแปลงหลักใน Swift 6.2 มาจาก Swift Evolution proposals สามตัวที่ทำงานร่วมกัน:
- SE-0466 — MainActor by Default
- SE-0461 — nonisolated(nonsending) เป็นค่าเริ่มต้น
- SE-0470 — @concurrent attribute และ Isolated Protocol Conformances
ว่าแล้ว มาดูรายละเอียดกันทีละตัวเลย
3 ขั้นตอนของ Progressive Disclosure ใน Concurrency
Swift 6.2 ออกแบบให้นักพัฒนาเข้าถึงระบบ concurrency ได้เป็น 3 ระดับ แต่ละระดับเพิ่มความซับซ้อนขึ้นเรื่อยๆ ซึ่งส่วนตัวผมว่าแนวคิดนี้ดีมาก
ระดับที่ 1: Sequential Code (โค้ดแบบลำดับ)
ระดับนี้ง่ายสุด เขียนโค้ดแบบปกติเหมือนที่เคยทำมา ทุกอย่างทำงานบน Main Actor โดยอัตโนมัติ ไม่ต้องคิดเรื่อง thread ไม่ต้องคิดเรื่อง data race เลย
// ระดับที่ 1: เขียนโค้ดปกติ ไม่ต้องคิดเรื่อง concurrency เลย
// ทุกอย่างทำงานบน Main Actor โดยอัตโนมัติ
struct ContentView: View {
@State private var items: [String] = []
var body: some View {
List(items, id: \.self) { item in
Text(item)
}
.onAppear {
items = ["Swift", "SwiftUI", "Xcode"]
}
}
}
ระดับที่ 2: Async/Await (การทำงานแบบ Asynchronous)
เมื่อต้องเรียก API หรือทำงานที่ต้องรอผลลัพธ์ ก็แค่ใช้ async/await ตามปกติ ฟังก์ชัน async จะทำงานบน executor เดียวกับผู้เรียก — ไม่มีการแอบย้าย thread โดยที่คุณไม่รู้ตัว
// ระดับที่ 2: เพิ่ม async/await เมื่อต้องการ
// ฟังก์ชัน async ยังคงทำงานบน Main Actor เหมือนเดิม
func loadItems() async throws -> [String] {
let url = URL(string: "https://api.example.com/items")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([String].self, from: data)
}
// เรียกใช้จาก View — ยังคงอยู่บน Main Actor
struct ContentView: View {
@State private var items: [String] = []
var body: some View {
List(items, id: \.self) { item in
Text(item)
}
.task {
do {
items = try await loadItems()
} catch {
print("Error: \(error)")
}
}
}
}
ระดับที่ 3: Parallelism (การทำงานแบบขนาน)
ระดับนี้สำหรับเมื่อต้องการประสิทธิภาพสูงสุดจริงๆ เช่น ประมวลผลข้อมูลจำนวนมาก ใช้ @concurrent เพื่อบอก compiler อย่างชัดเจนว่า "ย้ายไปทำบน background ได้เลย"
// ระดับที่ 3: ใช้ @concurrent เมื่อต้องการ parallelism จริงๆ
@concurrent
func processLargeDataSet(_ data: [DataPoint]) async -> ProcessedResult {
// ทำงานบน background thread โดยอัตโนมัติ
// เหมาะสำหรับงานหนักที่ไม่ต้องการอยู่บน Main Actor
var result = ProcessedResult()
for point in data {
result.add(transform(point))
}
return result
}
สิ่งที่ผมชอบเกี่ยวกับแนวคิดนี้คือ มือใหม่สามารถเริ่มที่ระดับ 1 ได้เลย แล้วค่อยๆ เรียนรู้ระดับ 2 และ 3 เมื่อจำเป็น ไม่ต้องถูกบังคับให้เข้าใจทุกอย่างตั้งแต่แรก
MainActor by Default (SE-0466)
นี่คือการเปลี่ยนแปลงที่สำคัญที่สุดใน Swift 6.2 และถือเป็นหัวใจของ Approachable Concurrency เลยก็ว่าได้
ปัญหาที่เกิดขึ้นใน Swift 6.0/6.1
ใน Swift 6.0 และ 6.1 เมื่อคุณเขียนฟังก์ชันหรือ class โดยไม่ระบุ isolation ใดๆ โค้ดเหล่านั้นจะเป็น nonisolated โดยปริยาย คือมันไม่ได้อยู่บน actor ใดเลย
ปัญหาคือเมื่อต้องเข้าถึง Main Actor (เช่น อัพเดท UI) จะต้องใช้ await ทำให้เกิด error เยอะมากที่นักพัฒนารู้สึกว่า "ไม่จำเป็น" — เพราะจริงๆ แล้วโค้ดส่วนใหญ่ในแอพ iOS/macOS ก็ทำงานบน main thread อยู่แล้วนี่นา
// Swift 6.0/6.1: ปัญหาที่พบบ่อย
class ProfileViewModel: ObservableObject {
@Published var name: String = ""
// ❌ Error: Main actor-isolated property 'name'
// can not be mutated from a nonisolated context
func updateName(_ newName: String) {
name = newName // class นี้เป็น nonisolated โดยปริยาย!
}
}
// ต้องแก้โดยเพิ่ม @MainActor เอง
@MainActor
class ProfileViewModel: ObservableObject {
@Published var name: String = ""
func updateName(_ newName: String) {
name = newName // ✅ ทำงานได้เพราะอยู่บน MainActor
}
}
วิธีแก้ของ Swift 6.2
SE-0466 เปลี่ยนค่าเริ่มต้นแบบจัดเต็ม — ทุกอย่างอยู่บน @MainActor โดยอัตโนมัติ ไม่ต้องเขียน annotation เองอีกต่อไป ทุก class, struct, function, property ที่ไม่ได้ระบุ isolation จะถูกถือว่าอยู่บน Main Actor
// Swift 6.2 (เมื่อเปิด MainActor by Default)
// ไม่ต้องเขียน @MainActor อีกต่อไป!
class ProfileViewModel: ObservableObject {
@Published var name: String = ""
func updateName(_ newName: String) {
name = newName // ✅ ทำงานได้เลย เพราะอยู่บน MainActor โดยอัตโนมัติ
}
func loadProfile() async throws {
let profile = try await fetchProfile()
name = profile.name // ✅ ยังคงอยู่บน MainActor ไม่ต้อง await กลับมา
}
}
แค่เห็นโค้ดก็รู้สึกโล่งแล้วใช่ไหม?
วิธีเปิดใช้งาน MainActor by Default
สิ่งสำคัญคือฟีเจอร์นี้ไม่ได้เปิดโดยอัตโนมัติ คุณต้องเปิดเอง ซึ่งทำได้หลายวิธี:
วิธีที่ 1: ผ่าน Xcode 26 Build Settings
ตั้งค่า SWIFT_APPROACHABLE_CONCURRENCY เป็น YES ในส่วน Build Settings ของ Xcode 26 วิธีนี้จะเปิดทั้ง SE-0466 และ SE-0461 ให้พร้อมกันเลย
วิธีที่ 2: ผ่าน Swift Package Manager
// Package.swift
// เปิดฟีเจอร์สำหรับ target เฉพาะ
.target(
name: "MyApp",
dependencies: [],
swiftSettings: [
.enableUpcomingFeature("MainActorByDefault"),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]
)
วิธีที่ 3: ผ่าน compiler flag
swiftc -enable-upcoming-feature MainActorByDefault MyFile.swift
จะทำอย่างไรเมื่อไม่ต้องการให้อยู่บน MainActor?
แน่นอนว่าไม่ใช่ทุกอย่างควรอยู่บน Main Actor ถ้าโค้ดบางส่วนไม่ควรอยู่ ก็แค่ใช้ nonisolated keyword ตามปกติ
// ต้องการให้ utility class ไม่อยู่บน MainActor
nonisolated class MathHelper {
// class นี้จะไม่อยู่บน MainActor
func calculate(_ values: [Double]) -> Double {
values.reduce(0, +) / Double(values.count)
}
}
// หรือระบุเฉพาะบาง method
class DataProcessor {
// method นี้อยู่บน MainActor (ค่าเริ่มต้น)
func updateUI() {
// อัพเดท UI ได้เลย
}
// method นี้ไม่อยู่บน MainActor
nonisolated func pureComputation(_ x: Int) -> Int {
x * x + 1
}
}
nonisolated(nonsending) — ค่าเริ่มต้นใหม่ (SE-0461)
ตรงนี้ค่อนข้างเทคนิคหน่อย แต่สำคัญมาก เพราะมันแก้ปัญหา "ความประหลาดใจ" ที่เกิดขึ้นกับ async functions ใน Swift เวอร์ชันก่อนหน้า
ปัญหาใน Swift 6.1
ใน Swift 6.1 ถ้าคุณเขียนฟังก์ชันที่เป็น nonisolated async มันจะถูกส่งไปทำงานบน global executor (background thread) โดยอัตโนมัติ แม้ว่าผู้เรียกจะอยู่บน Main Actor ก็ตาม
พูดง่ายๆ คือมันแอบย้าย thread ไปโดยที่คุณไม่ได้ตั้งใจ ซึ่งทำให้เกิด Sendable errors เพียบ
// Swift 6.1: พฤติกรรมที่สร้างความสับสน
nonisolated func formatData(_ data: Data) async -> String {
// ⚠️ ฟังก์ชันนี้ทำงานบน background thread เสมอ!
// แม้ว่าจะถูกเรียกจาก MainActor ก็ตาม
// นี่ทำให้เกิด Sendable errors มากมาย
// เพราะ data ต้อง "ข้าม" จาก MainActor ไป background
return String(data: data, encoding: .utf8) ?? ""
}
@MainActor
func handleResponse(_ data: Data) async {
// ❌ อาจเกิด error เพราะ data ต้อง cross actor boundary
let formatted = await formatData(data)
label.text = formatted
}
ปัญหาที่ตามมาก็คือ:
- ค่าที่ส่งเข้าไปต้องเป็น
Sendableเพราะต้อง "ข้าม" actor boundary - นักพัฒนาหลายคนไม่รู้ด้วยซ้ำว่า nonisolated async จะย้ายไป background thread
- เกิด Sendable errors ที่ดูไม่ make sense — แค่เรียกฟังก์ชันธรรมดาเองนะ ทำไมต้อง Sendable ด้วย?
วิธีแก้ใน Swift 6.2
SE-0461 เปลี่ยนค่าเริ่มต้นให้ nonisolated async functions ทำงานบน caller's executor แทน โดยใช้ semantic ใหม่ที่เรียกว่า nonisolated(nonsending) ซึ่งฟังดูซับซ้อน แต่จริงๆ แล้วพฤติกรรมกลับง่ายขึ้นมาก
// Swift 6.2: พฤติกรรมใหม่ที่เข้าใจง่ายกว่า
// ฟังก์ชันนี้จะทำงานบน executor เดียวกับผู้เรียก
// ถ้าเรียกจาก MainActor → ทำงานบน main thread
// ถ้าเรียกจาก background → ทำงานบน background thread
nonisolated func formatData(_ data: Data) async -> String {
// ✅ ไม่มีการ hop ข้าม thread
// ไม่ต้อง Sendable เพราะไม่ข้าม actor boundary
return String(data: data, encoding: .utf8) ?? ""
}
@MainActor
func handleResponse(_ data: Data) async {
// ✅ formatData ทำงานบน MainActor เหมือนกัน
// ไม่มี actor boundary crossing
let formatted = await formatData(data)
label.text = formatted
}
ตารางเปรียบเทียบพฤติกรรม
| สถานการณ์ | Swift 6.1 | Swift 6.2 |
|---|---|---|
nonisolated func foo() (sync) |
ทำงานบน caller's executor | ทำงานบน caller's executor (เหมือนเดิม) |
nonisolated func foo() async |
ย้ายไป global executor (background) | ทำงานบน caller's executor (เปลี่ยน!) |
| ส่งค่า non-Sendable เข้า nonisolated async | ❌ Compiler error | ✅ ทำงานได้เพราะไม่ข้าม boundary |
| ต้องการทำงานบน background จริงๆ | nonisolated async (ค่าเริ่มต้น) | ต้องใช้ @concurrent ระบุชัดเจน |
เปิดใช้งาน NonisolatedNonsendingByDefault
คุณเปิดใช้งาน feature flag NonisolatedNonsendingByDefault ได้แยกต่างหาก หรือจะเปิดพร้อมกันผ่าน SWIFT_APPROACHABLE_CONCURRENCY ใน Xcode 26 ก็ได้
// Package.swift — เปิดเฉพาะ SE-0461
.target(
name: "MyLibrary",
swiftSettings: [
.enableUpcomingFeature("NonisolatedNonsendingByDefault")
]
)
@concurrent Attribute (SE-0470)
โอเค ถ้าค่าเริ่มต้นเปลี่ยนให้ async functions ทำงานบน caller's executor แล้ว คำถามที่ตามมาก็คือ "แล้วถ้าเราต้องการให้มันไปทำงานบน background thread จริงๆ ล่ะ?"
คำตอบคือ @concurrent attribute ตัวใหม่นี่แหละ
เมื่อไหร่ควรใช้ @concurrent
ใช้ @concurrent เมื่อฟังก์ชันของคุณ:
- ทำงานหนัก เช่น ประมวลผลรูปภาพ หรือการคำนวณที่ใช้เวลานาน
- ไม่ควรอยู่บน Main Actor เพราะจะทำให้ UI ค้าง
- สามารถทำงานบน background thread ได้อย่างปลอดภัย
// ใช้ @concurrent สำหรับงานหนักที่ต้องการทำบน background
@concurrent
func processImage(_ imageData: Data) async throws -> UIImage {
// ทำงานบน background thread เสมอ
// ไม่ว่าจะถูกเรียกจาก MainActor หรือที่ใดก็ตาม
guard let cgImage = createCGImage(from: imageData) else {
throw ProcessingError.invalidData
}
let filtered = applyFilters(to: cgImage)
let resized = resize(filtered, to: CGSize(width: 1024, height: 1024))
return UIImage(cgImage: resized)
}
// เรียกใช้จาก MainActor
@MainActor
func handleImageUpload(_ data: Data) async {
do {
// processImage จะถูกส่งไปทำงานบน background
// data ต้องเป็น Sendable (Data เป็น Sendable อยู่แล้ว)
let image = try await processImage(data)
// กลับมาอยู่บน MainActor เมื่อ await เสร็จ
imageView.image = image
} catch {
showError(error)
}
}
ตารางเปรียบเทียบ: nonisolated, nonisolated(nonsending), @concurrent
| Attribute | Executor ที่ใช้ | ต้อง Sendable? | ใช้เมื่อ |
|---|---|---|---|
nonisolated (sync) |
Caller's executor | ไม่จำเป็น | ฟังก์ชันธรรมดาที่ไม่ใช่ async |
nonisolated(nonsending) |
Caller's executor | ไม่จำเป็น | ฟังก์ชัน async ที่ไม่ต้องย้าย thread (ค่าเริ่มต้นใน 6.2) |
@concurrent |
Global executor (background) | จำเป็น | งานหนักที่ต้องการทำบน background thread |
ข้อสังเกตสำคัญเกี่ยวกับ @concurrent
เมื่อใช้ @concurrent พารามิเตอร์และค่าที่ส่งคืนต้องเป็น Sendable เพราะมีการข้าม actor boundary จริงๆ นี่คือ trade-off ที่ชัดเจน — ถ้าอยากได้ประสิทธิภาพจาก background execution คุณก็ต้องรับรองว่าข้อมูลปลอดภัยต่อการข้าม thread
// ❌ จะ error เพราะ NonSendableData ไม่ Sendable
class NonSendableData {
var value: Int = 0
}
@concurrent
func process(_ data: NonSendableData) async {
// Error: Non-sendable type 'NonSendableData' cannot cross actor boundary
}
// ✅ ใช้ Sendable type แทน
struct SafeData: Sendable {
let value: Int
}
@concurrent
func process(_ data: SafeData) async -> SafeData {
return SafeData(value: data.value * 2)
}
Isolated Protocol Conformances (SE-0470)
อีกหนึ่งปัญหาที่เจอบ่อยมากใน Swift 6 คือการทำให้ type ที่อยู่บน @MainActor conform กับ protocol ที่ไม่ได้ระบุ isolation ใครเคยเจอบ้าง? ยกมือเลย
ปัญหาเดิม
// Protocol ที่ไม่ได้ระบุ isolation
protocol DataProvider {
func fetchData() async -> [String]
}
// Swift 6.0/6.1: ❌ Error!
@MainActor
class MyDataProvider: DataProvider {
var cache: [String] = []
// ❌ Main actor-isolated instance method 'fetchData()'
// cannot be used to satisfy nonisolated protocol requirement
func fetchData() async -> [String] {
if cache.isEmpty {
cache = await loadFromNetwork()
}
return cache
}
}
น่าหงุดหงิดมากเลยใช่ไหม? แค่จะ conform protocol ธรรมดาเอง
วิธีแก้ใน Swift 6.2
SE-0470 ทำให้ compiler ฉลาดขึ้น สามารถอนุมาน (infer) ได้ว่า protocol conformance สามารถเป็น isolated ได้ เมื่อเปิด feature flag InferIsolatedConformances
// Swift 6.2: ✅ ทำงานได้!
protocol DataProvider {
func fetchData() async -> [String]
}
@MainActor
class MyDataProvider: DataProvider { // Conformance ถูก infer เป็น @MainActor
var cache: [String] = []
func fetchData() async -> [String] {
if cache.isEmpty {
cache = await loadFromNetwork()
}
return cache // ✅ เข้าถึง cache ได้โดยไม่มี error
}
}
Compiler จะอนุมานว่า conformance นี้เป็น @MainActor isolated ซึ่งหมายความว่าเมื่อใช้ MyDataProvider เป็น DataProvider จะทำงานได้อย่างปลอดภัยภายใน MainActor context
ข้อจำกัดที่ต้องรู้
แต่ก็มีข้อจำกัดนะ — ถ้าคุณพยายามใช้ isolated conformance ใน context ที่ไม่ได้อยู่บน actor เดียวกัน compiler จะแจ้ง error
// ⚠️ ข้อจำกัดของ isolated conformance
@MainActor
class MyProvider: DataProvider {
func fetchData() async -> [String] { return [] }
}
// ✅ ใช้ได้ใน MainActor context
@MainActor
func useProvider() async {
let provider: any DataProvider = MyProvider()
let data = await provider.fetchData()
}
// ❌ ใช้ไม่ได้ถ้า context ไม่ตรงกัน
// compiler จะแจ้งเตือน
nonisolated func useProviderFromBackground() async {
let provider: any DataProvider = MyProvider()
// ⚠️ อาจต้อง await เพิ่มเติมเพื่อ hop ไปยัง MainActor
}
เปิดใช้งาน feature flag:
// Package.swift
.target(
name: "MyApp",
swiftSettings: [
.enableUpcomingFeature("InferIsolatedConformances")
]
)
ตัวอย่างโค้ดจริง: Before vs After
ทีนี้มาดูตัวอย่างที่ครอบคลุมกว่าเดิม เพื่อให้เห็นภาพชัดๆ ว่า Swift 6.2 เปลี่ยนแปลงโค้ดในชีวิตจริงยังไงบ้าง
ตัวอย่างที่ 1: ViewModel แบบเต็ม
Before (Swift 6.0/6.1):
// Swift 6.0/6.1 — ต้อง annotate ทุกอย่าง
@MainActor // ต้องเพิ่ม
class ArticleListViewModel: ObservableObject {
@Published var articles: [Article] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let repository: ArticleRepository
init(repository: ArticleRepository) {
self.repository = repository
}
func loadArticles() async {
isLoading = true
defer { isLoading = false }
do {
articles = try await repository.fetchAll()
} catch {
errorMessage = error.localizedDescription
}
}
// ต้องเพิ่ม nonisolated เพื่อทำงานบน background
nonisolated func searchArticles(_ query: String) async throws -> [Article] {
// ⚠️ ฟังก์ชันนี้ไปอยู่บน background thread โดยไม่ตั้งใจ
try await repository.search(query)
}
}
After (Swift 6.2 with Approachable Concurrency):
// Swift 6.2 — สะอาดและเรียบง่าย
// ไม่ต้องเพิ่ม @MainActor เลย!
class ArticleListViewModel: ObservableObject {
@Published var articles: [Article] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let repository: ArticleRepository
init(repository: ArticleRepository) {
self.repository = repository
}
func loadArticles() async {
isLoading = true
defer { isLoading = false }
do {
articles = try await repository.fetchAll()
} catch {
errorMessage = error.localizedDescription
}
}
func searchArticles(_ query: String) async throws -> [Article] {
// ✅ ทำงานบน MainActor เหมือนกัน (caller's executor)
// ไม่มีการ hop ไป background โดยไม่ตั้งใจ
try await repository.search(query)
}
}
เห็นความแตกต่างไหม? โค้ดสะอาดขึ้นเยอะมาก
ตัวอย่างที่ 2: Background Processing ด้วย @concurrent
// Swift 6.2: การผสมผสาน MainActor default กับ @concurrent
class PhotoProcessor {
// อยู่บน MainActor โดยอัตโนมัติ
var processedPhotos: [ProcessedPhoto] = []
var progress: Double = 0.0
func processAllPhotos(_ urls: [URL]) async throws {
let total = Double(urls.count)
for (index, url) in urls.enumerated() {
// โหลดข้อมูลจาก disk
let data = try Data(contentsOf: url)
// ส่งไปประมวลผลบน background thread
let processed = try await heavyProcessing(data)
// กลับมาอัพเดท UI บน MainActor โดยอัตโนมัติ
processedPhotos.append(processed)
progress = Double(index + 1) / total
}
}
// งานหนักที่ต้องทำบน background
@concurrent
func heavyProcessing(_ data: Data) async throws -> ProcessedPhoto {
// ทำงานบน background thread
let image = try decodeImage(data)
let enhanced = applyMLEnhancement(image)
let compressed = compress(enhanced, quality: 0.85)
return ProcessedPhoto(data: compressed)
}
}
// การใช้งานใน SwiftUI
struct PhotoGalleryView: View {
@StateObject private var processor = PhotoProcessor()
var body: some View {
VStack {
ProgressView(value: processor.progress)
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(processor.processedPhotos, id: \.id) { photo in
PhotoThumbnail(photo: photo)
}
}
}
}
.task {
do {
try await processor.processAllPhotos(selectedURLs)
} catch {
print("Processing failed: \(error)")
}
}
}
}
ตัวอย่างนี้แสดงให้เห็นว่า MainActor by default กับ @concurrent ทำงานร่วมกันได้อย่างราบรื่น — โค้ด UI อยู่บน Main Actor อัตโนมัติ ส่วนงานหนักก็ระบุชัดเจนว่าไปทำบน background
ตัวอย่างที่ 3: Protocol Conformance ที่ง่ายขึ้น
// Swift 6.2: Isolated Protocol Conformances
protocol Cacheable {
associatedtype Key: Hashable
associatedtype Value
func get(_ key: Key) async -> Value?
func set(_ key: Key, value: Value) async
func clear() async
}
// ก่อน Swift 6.2: ต้องทำ workaround มากมาย
// หลัง Swift 6.2: conform ได้ตรงๆ
class InMemoryCache: Cacheable {
// อยู่บน MainActor โดยอัตโนมัติ (MainActor by default)
private var storage: [String: Any] = [:]
func get(_ key: String) async -> Any? {
storage[key] // ✅ เข้าถึง storage ได้เลย
}
func set(_ key: String, value: Any) async {
storage[key] = value // ✅ เขียนได้เลย
}
func clear() async {
storage.removeAll() // ✅ ไม่มี error
}
}
คู่มือ Migration ทีละขั้นตอน
ส่วนนี้สำคัญมากสำหรับคนที่อยากลองใช้จริง การ migrate ควรทำอย่างเป็นระบบ อย่าเปิดทุกอย่างพร้อมกันแล้วนั่งนับ errors นะ (เชื่อผม ผมเคยลองแล้ว ไม่สนุก)
ขั้นตอนที่ 1: อัพเดท Xcode และ Swift
ก่อนอื่นเลย ตรวจสอบว่าคุณใช้ Xcode 26 ขึ้นไป ซึ่งมาพร้อมกับ Swift 6.2
swift --version
# Apple Swift version 6.2 (...)
ขั้นตอนที่ 2: เปิด Feature Flags ทีละตัว
แนะนำอย่างยิ่งให้เปิดทีละตัว จะได้จัดการ error ได้ง่ายขึ้น
สำหรับ Xcode Project:
- เปิด Build Settings ของ target
- ค้นหา
SWIFT_APPROACHABLE_CONCURRENCY - ตั้งค่าเป็น
YES
วิธีนี้จะเปิดทั้ง 3 features พร้อมกัน (MainActorByDefault, NonisolatedNonsendingByDefault, InferIsolatedConformances) แต่ถ้าอยากควบคุมทีละตัว ใช้ "Other Swift Flags" แทนได้:
// ใน Other Swift Flags (-Xswiftc) เพิ่มทีละตัว:
-enable-upcoming-feature NonisolatedNonsendingByDefault
-enable-upcoming-feature MainActorByDefault
-enable-upcoming-feature InferIsolatedConformances
สำหรับ Swift Package Manager:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MyPackage",
platforms: [.iOS(.v18), .macOS(.v15)],
products: [
.library(name: "MyLibrary", targets: ["MyLibrary"])
],
targets: [
// ขั้นที่ 1: เปิด NonisolatedNonsendingByDefault ก่อน
.target(
name: "MyLibrary",
swiftSettings: [
.enableUpcomingFeature("NonisolatedNonsendingByDefault")
]
),
// ขั้นที่ 2: เพิ่ม InferIsolatedConformances
// .enableUpcomingFeature("InferIsolatedConformances")
// ขั้นที่ 3: เพิ่ม MainActorByDefault
// .enableUpcomingFeature("MainActorByDefault")
]
)
ขั้นตอนที่ 3: แก้ไข Warnings และ Errors
เมื่อเปิด feature flags แล้ว compiler อาจแสดง warnings หรือ errors ที่ต้องจัดการ ข่าวดีคือ Swift 6.2 มาพร้อม fix-its ที่ช่วยแนะนำวิธีแก้ไข
กรณีที่พบบ่อย:
// กรณี 1: ฟังก์ชันที่เคยทำงานบน background โดยตั้งใจ
// ก่อน (เคยทำงานบน background โดยไม่ได้ตั้งใจ)
nonisolated func computeHash(_ data: Data) async -> String {
// ... heavy computation
}
// แก้ไข: เพิ่ม @concurrent ถ้าต้องการ background จริงๆ
@concurrent
func computeHash(_ data: Data) async -> String {
// ... heavy computation
}
// หรือปล่อยไว้ถ้าไม่จำเป็นต้องอยู่บน background
func computeHash(_ data: Data) async -> String {
// ... ถ้าเบาพอที่จะทำบน main thread ได้
}
// กรณี 2: โค้ดที่ไม่ควรอยู่บน MainActor
// เมื่อเปิด MainActorByDefault ทุกอย่างจะอยู่บน MainActor
class MathUtility {
func fibonacci(_ n: Int) -> Int {
if n <= 1 { return n }
return fibonacci(n - 1) + fibonacci(n - 2)
}
}
// แก้ไข: เพิ่ม nonisolated สำหรับ utility classes
nonisolated class MathUtility {
func fibonacci(_ n: Int) -> Int {
if n <= 1 { return n }
return fibonacci(n - 1) + fibonacci(n - 2)
}
}
ขั้นตอนที่ 4: ลบ @MainActor ที่ไม่จำเป็น
หลังจากเปิด MainActor by Default แล้ว คุณลบ @MainActor annotations ที่ซ้ำซ้อนออกได้เลย เพราะ compiler จะใส่ให้โดยอัตโนมัติ ขั้นตอนนี้ไม่บังคับ แต่ทำให้โค้ดสะอาดขึ้นเยอะ
// ก่อน: @MainActor ซ้ำซ้อน
@MainActor // ← ลบได้!
class SettingsViewModel: ObservableObject {
@MainActor // ← ลบได้!
@Published var isDarkMode = false
@MainActor // ← ลบได้!
func toggleDarkMode() {
isDarkMode.toggle()
}
}
// หลัง: สะอาดขึ้นมาก
class SettingsViewModel: ObservableObject {
@Published var isDarkMode = false
func toggleDarkMode() {
isDarkMode.toggle()
}
}
ขั้นตอนที่ 5: ทดสอบอย่างครอบคลุม
ขั้นตอนนี้ข้ามไม่ได้เลย หลัง migration ควรทดสอบอย่างละเอียด โดยเฉพาะเรื่องพวกนี้:
- ฟังก์ชันที่เคยทำงานบน background อาจมาอยู่บน main thread แล้ว — ตรวจสอบว่าไม่มี UI blocking
- Protocol conformances ที่เคยเป็น nonisolated อาจเปลี่ยนพฤติกรรม
- Third-party libraries ที่ยังไม่ได้อัพเดทอาจมี compatibility issues
ข้อผิดพลาดที่พบบ่อยและวิธีหลีกเลี่ยง
ส่วนนี้รวบรวมจากประสบการณ์จริงและสิ่งที่เห็นบ่อยใน community ลองเช็คดูว่าคุณเจอข้อไหนบ้าง
1. ลืมเพิ่ม @concurrent สำหรับงานหนัก
นี่คือข้อผิดพลาดอันดับหนึ่งเลย เมื่อทุกอย่างอยู่บน MainActor โดยอัตโนมัติ ฟังก์ชันที่ทำงานหนักก็จะทำให้ UI ค้างได้ถ้าลืมใส่ @concurrent
// ❌ ผิด: ฟังก์ชันนี้จะทำงานบน MainActor ทำให้ UI ค้าง
func processLargeFile(_ url: URL) async throws -> ProcessedData {
let data = try Data(contentsOf: url) // อ่านไฟล์ขนาดใหญ่บน main thread!
return try transform(data) // ประมวลผลบน main thread!
}
// ✅ ถูก: ใช้ @concurrent สำหรับงานหนัก
@concurrent
func processLargeFile(_ url: URL) async throws -> ProcessedData {
let data = try Data(contentsOf: url) // อ่านบน background thread
return try transform(data) // ประมวลผลบน background thread
}
2. ใช้ nonisolated กว้างเกินไป
อย่าเพิ่ม nonisolated ให้ทุกอย่างเพียงเพราะเห็น warning นะ ให้คิดก่อนว่าโค้ดนั้นจำเป็นต้องอยู่นอก MainActor จริงหรือเปล่า
// ❌ ผิด: ไม่ควรทำ nonisolated ทุกอย่างเพียงเพราะ warning
nonisolated class UserProfileManager {
// ตอนนี้ class นี้ไม่มี actor protection เลย
var currentUser: User? // ⚠️ ไม่ปลอดภัยถ้าเข้าถึงจากหลาย thread
}
// ✅ ถูก: ปล่อยให้อยู่บน MainActor (ค่าเริ่มต้น) ถ้ามันเหมาะสม
class UserProfileManager {
var currentUser: User? // ปลอดภัยเพราะอยู่บน MainActor
}
3. สับสนระหว่าง nonisolated กับ @concurrent
อันนี้เจอบ่อย ดูให้ชัดนะ — สองตัวนี้ต่างกันมาก:
// nonisolated: ไม่ได้อยู่บน actor ใด
// แต่ async version จะทำงานบน caller's executor (ไม่ย้าย thread)
nonisolated func helper() async -> String {
// ทำงานบน executor เดียวกับผู้เรียก
return "result"
}
// @concurrent: ย้ายไป background thread เสมอ
// พารามิเตอร์ต้อง Sendable
@concurrent
func backgroundHelper() async -> String {
// ทำงานบน background thread เสมอ
return "result"
}
4. ไม่อัพเดท swift-tools-version ใน Package.swift
เรื่องเล็กแต่ลืมกันเยอะมาก Feature flags ของ Swift 6.2 ต้องใช้ swift-tools-version: 6.2 ขึ้นไป ถ้ายังใช้เวอร์ชันเก่าก็จะเปิดฟีเจอร์พวกนี้ไม่ได้
// ❌ ผิด:
// swift-tools-version: 5.9
// ✅ ถูก:
// swift-tools-version: 6.2
5. ลืมพิจารณา Third-party Dependencies
ถ้า library ที่คุณใช้ยังไม่ได้อัพเดทสำหรับ Swift 6.2 ก็อาจมี compatibility issues ได้ ควรตรวจสอบก่อนว่า dependencies ทั้งหมดรองรับหรือไม่ และใช้ @preconcurrency import เพื่อ suppress warnings จาก libraries เก่าไปก่อน
// ใช้ @preconcurrency import สำหรับ libraries ที่ยังไม่อัพเดท
@preconcurrency import OldLibrary
// จะช่วย suppress Sendable warnings จาก types ใน OldLibrary
สรุปภาพรวมการเปลี่ยนแปลงทั้งหมด
| Feature | Proposal | Feature Flag | สิ่งที่เปลี่ยน |
|---|---|---|---|
| MainActor by Default | SE-0466 | MainActorByDefault |
ทุกอย่างอยู่บน @MainActor โดยอัตโนมัติ ไม่ต้อง annotate เอง |
| Nonisolated Nonsending | SE-0461 | NonisolatedNonsendingByDefault |
nonisolated async ทำงานบน caller's executor แทน global executor |
| @concurrent และ Isolated Conformances | SE-0470 | InferIsolatedConformances |
Opt-in background execution + protocol conformance ที่เป็น isolated ได้ |
| Xcode Integration | — | SWIFT_APPROACHABLE_CONCURRENCY |
Build setting ใน Xcode 26 ที่เปิดทุก feature พร้อมกัน |
คำถามที่พบบ่อย (FAQ)
เปิด Approachable Concurrency แล้วจะทำให้แอพช้าลงไหม?
คำตอบสั้นๆ คือ "ไม่น่าจะช้าลงอย่างมีนัยสำคัญ" สำหรับแอพส่วนใหญ่ ในความเป็นจริง โค้ดส่วนมากในแอพ iOS/macOS ก็ทำงานบน main thread อยู่แล้ว สิ่งที่ Swift 6.2 ทำคือเปลี่ยนค่าเริ่มต้นให้ตรงกับความจริง
แต่มีเรื่องที่ต้องระวัง — ถ้าคุณมีงานหนักที่เคย "บังเอิญ" ไปอยู่บน background thread เพราะเป็น nonisolated async ตอนนี้มันจะกลับมาอยู่บน main thread แทน คุณควรใช้ @concurrent สำหรับงานเหล่านั้นอย่างชัดเจน ซึ่งจริงๆ แล้วเป็นแนวปฏิบัติที่ดีกว่าเดิมอยู่แล้ว เพราะรู้แน่ชัดว่าอะไรทำงานที่ไหน
ต้องใช้ Swift 6 Language Mode ก่อนถึงจะเปิด Approachable Concurrency ได้ไหม?
Feature flags พวกนี้ (MainActorByDefault, NonisolatedNonsendingByDefault, InferIsolatedConformances) ใช้ร่วมกับ Swift 6 language mode ได้เลย และออกแบบมาเพื่อช่วยลดจำนวน concurrency errors ที่คุณเจอ
ถ้าคุณกำลังพยายาม migrate ไป Swift 6 language mode อยู่ การเปิด Approachable Concurrency พร้อมกันจะช่วยลด errors ลงอย่างมาก เพราะ compiler จะจัดการ isolation ให้ฉลาดขึ้นเยอะ
ถ้าใช้ SwiftUI อยู่แล้ว ยังต้องเปลี่ยนอะไรไหม?
SwiftUI ถูกออกแบบมาให้ทำงานบน MainActor อยู่แล้วเป็นส่วนใหญ่ (View, @Observable, @State ล้วนเป็น MainActor isolated) ดังนั้นสำหรับ SwiftUI apps การเปิด Approachable Concurrency จะทำให้โค้ดสะอาดขึ้นมากเลย
คุณจะลบ @MainActor annotations ที่เคยต้องเพิ่มใน ViewModels และ classes ต่างๆ ได้ทั้งหมด สิ่งที่ต้องระวังก็แค่ฟังก์ชันที่ทำงานหนัก (เช่น image processing, data parsing) ควรเพิ่ม @concurrent เพื่อไม่ให้ block UI
Library ที่ต้อง support ทั้ง Swift 6.1 และ 6.2 ควรทำอย่างไร?
สำหรับ library maintainers นี่เป็นช่วงเปลี่ยนผ่านที่ต้องระวังหน่อย คำแนะนำคือ:
- เพิ่ม
@concurrentอย่างชัดเจนสำหรับฟังก์ชันที่ต้องทำงานบน background (ใช้ได้ใน Swift 6.2 ไม่ว่าจะเปิด feature flag หรือไม่) - ยังคงใส่
@MainActorใน public API เพื่อความชัดเจน แม้จะซ้ำซ้อนเมื่อเปิด MainActorByDefault - ระบุ
swift-tools-versionต่ำสุดที่ต้องการ support ใน Package.swift
มี Migration Tool หรือ Fix-its อะไรบ้าง?
Swift 6.2 และ Xcode 26 มาพร้อมเครื่องมือช่วยหลายอย่าง:
- Compiler Fix-its — แนะนำวิธีแก้ไขเมื่อเจอ warning หรือ error ใหม่ เช่น แนะนำให้เพิ่ม
@concurrentหรือnonisolated - Build setting —
SWIFT_APPROACHABLE_CONCURRENCYเปิดทุกอย่างพร้อมกันด้วยการตั้งค่าเดียว - Deprecation warnings — แจ้งเตือนเมื่อพบ pattern เก่าที่ควรเปลี่ยน
แนวทางที่แนะนำคือเริ่มจาก NonisolatedNonsendingByDefault ก่อน เพราะมักจะลดจำนวน errors แทนที่จะเพิ่ม จากนั้นค่อยเปิด InferIsolatedConformances และ MainActorByDefault ตามลำดับ