Swift Concurrency: TaskGroup ו-async let - המדריך המעשי לביצוע מקבילי (iOS 26)
TaskGroup ו-async let הם הפרימיטיבות המרכזיות של Structured Concurrency ב-Swift. מדריך מעשי עם דוגמאות קוד ל-iOS 26, כולל DiscardingTaskGroup, ביטול משימות, Sendable, ושגיאות נפוצות בקוד production.
TaskGroup ו-async let הם שתי הפרימיטיבות המרכזיות של Structured Concurrency ב-Swift לביצוע פעולות אסינכרוניות במקביל. השתמש ב-async let כשיש לך מספר קבוע של משימות עם טיפוסי החזרה שונים, וב-TaskGroup כשמספר המשימות דינמי או שכולן מחזירות את אותו הטיפוס. המדריך הזה מכסה את שני המנגנונים ב-Swift 6.2 עם Xcode 26 ו-iOS 26 - כולל DiscardingTaskGroup, ביטול, Sendable, ושגיאות נפוצות שאני נתקל בהן כשאני מייעץ לצוותים שעוברים ממסגרות ישנות כמו OperationQueue.
async let מיועד למספר קבוע של משימות מקבילות עם טיפוסים הטרוגניים; TaskGroup למספר דינמי של משימות הומוגניות.
ב-Swift 5.9 נוספו DiscardingTaskGroup ו-ThrowingDiscardingTaskGroup - חובה עבור לולאות אינסופיות כדי למנוע דליפת זיכרון.
Swift 6.2 מכניס Approachable Concurrency: בידוד @MainActor כברירת מחדל ו-nonisolated(nonsending) - זה משפיע ישירות על התנהגות TaskGroup.
ביטול הוא שיתופי (cooperative): Task.cancel() רק מסמן דגל - חובה לקרוא ל-Task.checkCancellation() בתוך לולאות CPU-intensive.
הסגור של addTask מסומן @Sendable, כך שכל מה שהוא לוכד חייב להיות בטוח לתחרות נתונים - ב-Swift 6.0+ זה נאכף בזמן קומפילציה.
Task.detached לא יורש את ה-actor, עדיפות, או ערכי task-local של ההקשר - הסיבה מספר אחת ל"למה עדכון ה-UI שלי רץ ב-background thread".
מה זה TaskGroup ומה זה async let ב-Swift Concurrency?
TaskGroup ו-async let הן שתי הפרימיטיבות של Structured Concurrency ב-Swift, שהוצגו רשמית ב-SE-0304 ב-Swift 5.5. הרעיון המרכזי הוא פשוט: כל משימה בת (child task) חייבת להסתיים לפני שההקשר האב ממשיך. זה שונה מהותית מ-Task { } הלא-מובנה, שיוצר משימה עצמאית לחלוטין ללא מחויבות זמנים לסביבה.
הבחירה בין השניים היא בעיקר בחירת ארגונומיה. async let מוסיף רק שתי מילות מפתח לחתימה קיימת - הקומפיילר בעצם עוטף את הביטוי ב-child task ומאחסן את התוצאה עד לקריאת await. TaskGroup, לעומת זאת, נותן שליטה תוכניתית: אפשר להוסיף משימות בתוך לולאה, לצרוך תוצאות כמו AsyncSequence, ולבטל את כולן בקריאה אחת. ב-Xcode 26 כשאתה עובד ב-@MainActor כברירת מחדל (הגדרת Swift 6.2 החדשה), שני המנגנונים ממשיכים לרוץ באותה ההקשר של ה-actor אלא אם צוין אחרת - זה שינוי משמעותי שיוצר צורך לחשוב מחדש על מיקום ה-CPU work.
מסביב לשני המנגנונים הללו מסתובבת מערכת שלמה: Sendable לבטיחות טיפוסים בין הקשרים, @MainActor להבטחת עדכוני UI ב-thread הראשי, ו-Task.checkCancellation() לביטול שיתופי. כדי להעריך במלואו את המנגנונים - חובה להבין את מודל בידוד האקטור החדש של Swift 6.2, שמשנה את הכללים לגבי מה שרץ ב-main thread מבלי שביקשת.
TaskGroup מול async let: טבלת השוואה
הטבלה שלמטה מסכמת את ההבדלים המעשיים בין async let ל-TaskGroup על סמך המימד שבאמת חשוב: מבנה הבעיה שאתה פותר. אם בחרת לא נכון, הקוד יעבוד - אבל יהיה מסורבל או קשה לתחזוקה בהמשך.
קריטריון
async let
TaskGroup
מספר משימות
קבוע וידוע בזמן קומפילציה
דינמי - נקבע בזמן ריצה
טיפוסי החזרה
הטרוגניים - כל משימה יכולה להחזיר טיפוס אחר
הומוגניים - כל הילדים חייבים להחזיר ChildTaskResult
סדר צריכת תוצאות
סדר קבוע ומוגדר ב-await
לפי סיום בפועל - הראשון שסיים ראשון
ביטול תוכניתי
רק דרך יציאה מהסקופ
group.cancelAll()
הוספת משימות בלולאה
לא נתמך
group.addTask { ... }
הפצת שגיאות
תלוי בסדר try await
שגיאה ראשונה זוכה, השאר מתבטלות
overhead קומפיילציה
מינימלי - סוכר תחבירי
מבנה מלא - יש מחיר קטן ליצירה
מקרה שימוש טיפוסי
טעינה מקבילה של 2-4 משאבים שונים
עיבוד קולקציה של פריטים במקביל
שים לב לשורה של הפצת שגיאות: זה נושא שרבים מפספסים. עם async let, שגיאה ממשימה שעדיין לא חיכית לה לא מתגלה עד ה-try await שלה - מה שיכול להוביל לזמן ריצה מבוזבז. עם TaskGroup, השגיאה הראשונה שנזרקת מכל ילד מבטלת אוטומטית את כל האחרים ומופצת החוצה מיד.
מתי כדאי להשתמש ב-TaskGroup ב-Swift?
הכלל המעשי שאני מלמד: התחל תמיד עם async let. אם אתה מוצא את עצמך כותב async let a1, async let a2, async let a3... ומכניס אותם למערך - עבור ל-TaskGroup. אם המספר לא ידוע בזמן קומפילציה, אין לך ברירה.
ארבעה מקרים ברורים שדורשים TaskGroup:
קולקציה עם מספר משתנה של פריטים: עיבוד רשימת קבצים, שליחת בקשות ל-N נקודות קצה, המרת רשימת תמונות. מספר הפריטים תלוי בנתונים.
דפוס "המהיר ביותר זוכה": שלח בקשה למספר שרתי CDN, קח את התוצאה הראשונה ובטל את השאר. group.next() בשילוב עם cancelAll() נותן זאת.
עיבוד תוצאות כמו זרם (streaming): כאשר אתה רוצה להתחיל לעבד תוצאה של משימה ברגע שהיא זמינה, לא לחכות שכולן יסיימו. for await result in group נותן זאת.
לולאות ארוכות טווח (event loops, servers): DiscardingTaskGroup נועד בדיוק לכך - שחרור זיכרון של משימות שהסתיימו.
מקרים שבהם async let נצחון מוחלט: טעינת נתונים למסך פרופיל (משתמש, הזמנות, סטטיסטיקות - שלושה טיפוסים שונים, מספר ידוע). זו הסיטואציה שבה TaskGroup היה יוצר קוד לא-typesafe עם Any או enum wrappers מסורבלים.
שימוש בסיסי ב-TaskGroup עם דוגמת קוד
הנה שני קטעי קוד שמתחילים כמעט בכל פרויקט iOS שאני עובד עליו. הראשון מדגים async let לטעינת דשבורד:
// async let - טעינת דשבורד עם 3 סוגי נתונים במקביל
func loadDashboard(userId: UUID) async throws -> Dashboard {
async let user = fetchUser(id: userId)
async let orders = fetchOrders(for: userId)
async let stats = fetchAnalytics(for: userId)
// כל שלושת ה-await קורים במקביל
return try await Dashboard(
user: user,
orders: orders,
stats: stats
)
}
שלוש הבקשות רצות במקביל מרגע ההצהרה על async let, גם אם ה-await קורה מאוחר יותר. הקוד קריא כמעט כמו קוד סינכרוני, אבל זמן הריצה הוא של הבקשה האיטית ביותר, לא של סכומן.
לעומת זאת, כשמספר הפריטים דינמי - עוברים ל-ThrowingTaskGroup:
// TaskGroup - טעינה מקבילה של N משתמשים
func fetchUsers(ids: [UUID]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
// addTaskUnlessCancelled מונע הוספת משימות מיותרות
// אחרי שהקבוצה כבר בוטלה
group.addTaskUnlessCancelled {
try await fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
שים לב ל-addTaskUnlessCancelled במקום addTask. זו הרגל ההגיינה שאני ממליץ עליו תמיד: הפונקציה בודקת אם הקבוצה כבר בוטלה לפני הוספת משימה חדשה. בלולאה של 10,000 פריטים זה יכול לחסוך אלפי משימות מיותרות שיווצרו רק כדי להתגלות מבוטלות מיד.
ThrowingTaskGroup: טיפול בשגיאות והפצתן
ההבדל בין TaskGroup ל-ThrowingTaskGroup הוא לא רק שהאחרון יכול לזרוק - זה משנה את התנהגות הביטול לחלוטין. כאשר ילד ב-ThrowingTaskGroup זורק שגיאה, Swift מבטל אוטומטית את כל הילדים האחרים לפני שהשגיאה מופצת החוצה. זה קורה גם אם הסקופ יוצא נורמלית עם throw.
הדוגמה הבאה מדגימה את הדפוס:
enum DownloadError: Error {
case corrupted(URL)
case timeout
}
func downloadAllOrFail(urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: (Int, Data).self) { group in
for (index, url) in urls.enumerated() {
group.addTask {
let data = try await URLSession.shared.data(from: url).0
guard data.count > 0 else {
throw DownloadError.corrupted(url)
}
return (index, data)
}
}
// אוסף לפי אינדקס כדי לשמר סדר
var results = Array(repeating: nil, count: urls.count)
for try await (index, data) in group {
results[index] = data
}
return results.compactMap { $0 }
}
// אם כל אחת מהמשימות זורקת - האחרות מתבטלות אוטומטית
// והשגיאה הראשונה מופצת מהפונקציה החוצה
}
אם אתה רוצה להתעלם משגיאות של משימות ספציפיות ולהחזיר רק את הצלחות - השתמש ב-try? בתוך ה-addTask. אם משימה זורקת והפונקציה שלך אמורה להמשיך - זה הדפוס. אבל שים לב: זה מסתיר לחלוטין את השגיאה, כך שכדאי לוגר.
DiscardingTaskGroup ל-iOS 17+ ומשימות ארוכות טווח
DiscardingTaskGroup ו-ThrowingDiscardingTaskGroup, שהוצגו ב-SE-0381 עבור Swift 5.9 (iOS 17+), נועדו לפתור בעיה ספציפית: אחסון תוצאות של משימות שהסתיימו ב-TaskGroup רגיל צורך זיכרון. עבור שרת שרץ שנים ומעבד מיליוני חיבורים - זה זליגת זיכרון קלאסית.
// דפוס server loop קלאסי עם DiscardingTaskGroup
func runServer(listener: NetworkListener) async {
await withDiscardingTaskGroup { group in
for await connection in listener.incomingConnections {
group.addTask {
await handleConnection(connection)
// התוצאה נזרקת מיד; הזיכרון משוחרר
}
}
}
}
// גרסה עם טיפול שגיאות: השגיאה הראשונה מבטלת את כל השאר
func processStream(events: AsyncStream<Event>) async throws {
try await withThrowingDiscardingTaskGroup { group in
for await event in events {
group.addTask {
try await processEvent(event)
}
}
}
}
ההבדל התנהגותי מ-TaskGroup רגיל שרץ עם for await _ in group { }: הגרסה הרגילה עדיין מחזיקה מטא-דאטה של המשימות עד סיום הקבוצה, בעוד ש-DiscardingTaskGroup משחרר את המשימה מיד עם סיומה. ב-benchmark של 100,000 משימות קצרות ראיתי הבדל של 30-40% בשימוש זיכרון פייק.
איך לבטל TaskGroup ב-Swift?
ביטול ב-Swift Concurrency הוא שיתופי (cooperative), לא preemptive. זה אומר ש-task.cancel() או group.cancelAll() רק מסמנים דגל בוליאני על המשימה - הקוד עצמו חייב לבדוק את הדגל ולהחליט מה לעשות. אם אתה כותב לולאה כבדה של CPU בלי בדיקת ביטול, שום דבר לא יעצור אותה.
שלוש הדרכים הרגילות לבדוק ולהגיב לביטול:
// 1. Task.checkCancellation() - זורק CancellationError
func processBatch(_ items: [Item]) async throws -> [Result] {
var results: [Result] = []
for item in items {
try Task.checkCancellation() // עוצר עם שגיאה
results.append(try await process(item))
}
return results
}
// 2. Task.isCancelled - בדיקה שקטה, מחזיר Bool
func processBatchSilent(_ items: [Item]) async -> [Result] {
var results: [Result] = []
for item in items {
if Task.isCancelled { break } // יוצא בלי שגיאה
if let r = try? await process(item) {
results.append(r)
}
}
return results
}
// 3. withTaskCancellationHandler - הפעלת קוד נקוי בזמן ביטול
func downloadWithCleanup(url: URL) async throws -> Data {
let downloader = FileDownloader()
return try await withTaskCancellationHandler {
try await downloader.download(from: url)
} onCancel: {
downloader.cancel() // מבטל את הבקשה הרשתית עצמה
}
}
ב-TaskGroup, קריאה ל-cancelAll() מסמנת את כל הילדים כמבוטלים, אבל לא עוצרת אותם באלימות. בנוסף, כל ילד חדש שיתווסף עם addTask אחרי הביטול יווצר כבר במצב מבוטל - שם נכנס addTaskUnlessCancelled לתמונה, שמדלג לגמרי על ההוספה.
Sendable ובטיחות תחרות נתונים ב-Swift 6.2
הסגור של group.addTask { ... } מסומן @Sendable, מה שאומר שכל מה שהוא לוכד חייב להיות בטוח להעברה בין הקשרים מקבילים. ב-Swift 5.x זה היה אזהרה; ב-Swift 6.0+ עם strict concurrency checking זו שגיאת קומפילציה. ב-Swift 6.2 עם Approachable Concurrency הכללים השתנו שוב: @MainActor הוא ברירת המחדל, אז הרבה מה-Sendable-warnings נעלמו כי הכל רץ ב-main.
// שגיאה קלאסית: לכידת reference type לא-Sendable
class Counter { // מחלקה רגילה - לא Sendable
var value = 0
func increment() { value += 1 }
}
func broken() async {
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask {
counter.increment() // ⚠️ Swift 6: שגיאה - Counter לא Sendable
}
}
}
}
// פתרון 1: השתמש ב-actor
actor SafeCounter {
private(set) var value = 0
func increment() { value += 1 }
}
func fixed() async {
let counter = SafeCounter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask {
await counter.increment() // ✅ actor - בטוח
}
}
}
print(await counter.value)
}
ב-תיעוד הרשמי של TaskGroup, Apple מדגישים ש-ChildTaskResult - טיפוס ההחזרה של המשימות הבנות - חייב להתאים גם הוא ל-Sendable. עבור טיפוסי-ערך פשוטים (Int, String, structs עם שדות Sendable), זה קורה אוטומטית. עבור מחלקות תצטרך להוסיף final class MyResult: Sendable ולהגן על המצב הפנימי.
למי שרוצה להעמיק בבידוד ובכללים החדשים של Swift 6.2, כתבתי מדריך מלא על בידוד אקטור ברירת מחדל ב-Swift 6.2 שמתאר איך SWIFT_DEFAULT_ACTOR_ISOLATION משנה את חיי היום-יום.
עדיפויות והורשת הקשר: Task מול Task.detached
הכלל: משימות ילדות מובנות (async let, TaskGroup) יורשות מהאב הכל - עדיפות, בידוד actor, וערכי @TaskLocal. Task { } לא-מובנה יורש עדיפות ו-actor מההקשר שיצר אותו. Task.detached { } לא יורש כלום. השלישי הוא הסיבה מספר אחת לבאגי "למה עדכון ה-UI שלי לא עובד".
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var user: User?
func load(id: UUID) {
Task {
// רץ ב-MainActor - הורש מהמתודה
self.user = try? await fetchUser(id: id)
}
Task.detached {
// לא רץ ב-MainActor - הורש מ-nothing
// זה יגרום לאזהרה או שגיאה בגישה ל-self.user
let user = try? await fetchUser(id: id)
await MainActor.run {
self.user = user // חובה לחזור למיין ידנית
}
}
}
}
העדיפויות ב-Swift Concurrency הן: .userInitiated, .high, .medium, .low, .utility, .background. השם שלהן פחות חשוב מהסמנטיקה: מערכת ההפעלה מקצה זמן CPU יחסי לפי עדיפות. משימה ב-.background תקבל CPU רק כשאין משימות אחרות שרוצות אותו. אני נמנע לרוב מהגדרה מפורשת של עדיפות - הורשה מהאב עובדת טוב ברוב המקרים, וכשאתה מציין ידנית אתה נלחם במערכת.
נקודה שרבים מפספסים: אם אתה מתחיל משימה ב-@MainActor, כל ה-await בתוכה חוזרים ל-main thread כברירת מחדל ב-Swift 6.2 (בזכות nonisolated(nonsending)). זה שונה מ-Swift 5, שם await היה יכול להחזיר אותך ל-thread שרירותי. השינוי הזה משפיע ישירות על ביצועי TaskGroup ב-UI code.
טעויות נפוצות שאני רואה בקוד production
אחרי בדיקת עשרות code reviews של צוותים שעוברים ל-Swift Concurrency, יש חמש שגיאות שחוזרות בכל פרויקט. אני מסכם אותן כאן כדי שתחסוך לעצמך זמן debug.
1. שכחת try/await ב-TaskGroup עם for-loop
הקוד הזה מתקמפל אבל לא מבצע כלום במקביל:
// שגוי - הפונקציה סינכרונית, האיטרציה סדרתית
await withTaskGroup(of: Int.self) { group in
for i in 0..<10 {
group.addTask { compute(i) } // אין await - זה יגרום לשגיאה אמיתית
}
}
2. שימוש ב-Task במקום async let כשאין צורך
מפתחים חדשים כותבים Task { } לכל דבר ומאבדים את יתרונות Structured Concurrency. Task { } לא-מובנה אינו מתבטל אוטומטית כשהאב מתבטל.
3. שימוש ב-Task.detached ליצירת "אמיתי" concurrency
יש אמונה טפלה ש-Task.detached "יותר מקבילי". זה לא נכון - כל Task { } רץ באותו pool של threads. detached רק מבטל את ההורשה, מה שכמעט תמיד יוצר יותר בעיות ממה שהוא פותר.
4. שימוש ב-TaskGroup רגיל ללולאות אינסופיות
אם יש לך event loop או server שמעבד אלפי חיבורים במהלך היום - TaskGroup רגיל יאסוף מטא-דאטה של כל משימה שהסתיימה. השתמש ב-DiscardingTaskGroup.
5. הזנחת ביטול בלולאות CPU-intensive
לולאה שמעבדת תמונה של 4K פיקסלים לא תעצור מיד כשמשתמש לוחץ Cancel, אלא אם תוסיף try Task.checkCancellation() בתוך הלולאה. שאני מעביר על קוד iOS ורואה עיבוד תמונה בלי בדיקות ביטול - זה תמיד bug reports של "האפליקציה נתקעת אחרי סגירת המסך".
אם אתה כותב טסטים ל-code אסינכרוני, בדוק את המדריך למסגרת Swift Testing - היא תומכת ב-async באופן טבעי ומקלה על בדיקות של TaskGroups. הפרימיטיבה #expect יכולה להמתין לתוצאות מקבילות בלי ה-boilerplate של XCTestExpectation.
שאלות נפוצות
האם async let רץ באמת במקביל או ברצף?
async let יוצר child task שמתחיל לרוץ מיד מרגע ההצהרה, במקביל להמשך הקוד. הביטוי מבוצע בפועל במקביל. ה-await רק ממתין לתוצאה הכבר-מחושבת. אם שלושה async let מוצהרים ברצף, שלוש הבקשות יתחילו כמעט בו-זמנית.
האם Task.cancel() עוצר את הביצוע מיד?
לא. הביטול ב-Swift Concurrency הוא שיתופי - cancel() רק מסמן דגל. הקוד עצמו חייב לקרוא ל-Task.checkCancellation() או לבדוק את Task.isCancelled ולהגיב. בקשות רשת של Apple (URLSession) כן מגיבות אוטומטית לביטול.
מה זה DiscardingTaskGroup ומתי משתמשים בו?
DiscardingTaskGroup, שנוסף ב-Swift 5.9, זורק את התוצאות של משימות שהסתיימו במקום לצבור אותן בזיכרון. הוא נועד ללולאות ארוכות טווח כמו שרתים ו-event loops, שם שמירת מטא-דאטה של אלפי משימות שהסתיימו הייתה יוצרת דליפת זיכרון.
האם צריך [weak self] בתוך Task { }?
לא בהכרח. אם ה-Task { } מסתיים במהירות - אין צורך; retain cycle לא נוצר. אבל אם אתה שומר את ה-Task כ-property על self (למשל כדי לבטל אותה מאוחר יותר), אז כן - חובה [weak self] כדי להימנע מ-retain cycle קלאסי.
האם עדיין כדאי להשתמש ב-OperationQueue ב-Swift מודרני?
ברוב המקרים לא. Structured Concurrency נותנת ergonomics טובים יותר, בדיקת קומפילציה חזקה של Sendable, וביטול אינטגרלי. יוצאי דופן: אם אתה צריך dependencies מורכבות בין משימות שלא ניתן לבטא ב-async/await, או integration עם קוד Objective-C ישן שכבר משתמש ב-OperationQueue.
המדריך המלא ל-@Observable ב-SwiftUI על iOS 26: איך המקרו החדש מחליף את ObservableObject ואת @Published, מיגרציה מסך אחר מסך, שילוב עם SwiftData, ביצועים אמיתיים ומלכודות מהפרודקשן.
מדריך מלא ל-SwiftData ב-iOS 26: הגדרת מודלים עם @Model, שאילתות ריאקטיביות עם @Query, קשרים, מיגרציית סכימה, סנכרון CloudKit, ומעבר מ-Core Data. כולל דוגמאות קוד עובדות בפרודקשן.