Swift Concurrency: actor

틀틀보·2025년 4월 29일

Swift Concurency

목록 보기
3/11

동시성 프로그래밍에서 data race를 방지하고 안전하게 상태를 관리하기 위한 새로운 참조 타입

data race

여러 스레드에서 동일한 메모리 위치를 읽거나 쓰려고 할 때 발생하는 문제
ex) 동일한 하나의 객체의 프로퍼티에 2개의 스레드가 추가, 삭제 작업을 동시에 진행함으로써 잘못된 데이터 발생

class Counter {
	private var count = 0
	
	func increase() {
		count += 1
	}
}
  • 각기 다른 곳에서 동일한 Counter 객체를 참조
  • 해당 객체의 increase 메서드가 여러 곳에서 비동기적으로 동작
  • 해당 객체 내부의 count 프로퍼티에 data race 발생
    공유가 가능하고, 변경이 가능한 상태(shared mutable state) 이기 때문.
var counter = Counter()

DispatchQueue.global().async {
    counter.increase() // mutating - 공유된 인스턴스 변경 → 위험!
}
DispatchQueue.global().async {
    counter.increase() // 동시에 접근하면 data race 발생 가능!
}

구조체도 피할 수 없다.

actor

  • class와 유사한 새로운 타입
  • 프로퍼티, 메서드, 이니셜라이저 등등을 모두 가질 수 있음.
  • 프로토콜, extension도 가능
  • 참조타입
  • 상속 지원 x

특징

내부의 프로퍼티에 동시에 접근되지 않도록 보장

actor Counter {
	var count: Int = 0
	
	func increase() {
		count += 1
	}
}

//외부
let counter = Counter()
await counter.increase()
  • actor 내부의 메서드가 프로퍼티를 변경시키는 동작을 할 경우, 외부에서 호출 시 await으로 비동기적으로 호출되게 함.
  • actor는 해당 동작을 데이터레이스가 발생하지 않을 시점에 알아서 동작을 처리.

actor 내부의 프로퍼티 접근은 무조건 self로 접근

actor Counter {
	var count: Int = 0
	
	func increase(diffCounter: Counter) {
		self.count += 1
		diffCounter.count += 1 🚨 에러 발생!
        남의 격리 영역에 침범하여 수정 불가
	}
}
  • actor 내부에 정의된 프로퍼티, instance 메서드는 기본적으로, actor-isolated 상태.
  • actor-isolated 인 애들은 무조건 self로 접근되어야 함.
actor Counter {
	var count: Int = 0
	let name: String = "hello"
	
	func increase(diffCounter: Counter) {
		self.count += 1
		print(diffCounter.name)
	}
}
  • 다만 불변 상태일 경우에는 접근 가능 (cross-actor reference)
  • 단, 다른 모듈에서는 불가.
  • 다른 모듈에서 접근 시, await을 동한 비동기 접근만 가능
  • 이유는 Counter를 정의한 모듈에서 모종의 이유로 namevar로 변경될 경우를 대비하기 위함.
  • 즉, 다른 모듈로의 영향을 최소화하기 위함. (다른 모듈에서 코드 모두에 await을 추가하는 불상사를 막기 위해)

actor 내부: 동기, 외부: 비동기

actor Counter {
	var count: Int = 0
	let name: String = "hello"
	
	func increase(diffCounter: Counter) {
		self.count += 1
        power() // 동기 호출 가능
		await diffCounter.decrease() 비동기로 호출
	}
}

extension Counter {
	func decrease() {
		self.count -= 1
	}
    
    func power() {
    	self.count *= selfcount
    }
}
  • 해당 객체 내부의 프로퍼티를 조작하는 메서드는 동기적으로 동작.
  • 해당 객체 내부가 아닌 외부에서 메서드가 호출될 경우 비동기적으로 동작
  • 따로 async 키워드를 추가하지 않더라도 알아서 처리

isolated, nonisolated

isolated

해당 메서드, 프로퍼티가 actor의 격리된 상태에 안전하게 접근할 수 있음을 명시
이 코드는 반드시 특정 Actor의 보호 영역(실행 컨텍스트) 안에서 실행되어야 한다"고 명시

기본적으로 actor 내부의 메서드, 프로퍼티는 isolated로 간주

actor BankAccount {
  let accountNumber: Int
  var balance: Double

  init(accountNumber: Int, initialDeposit: Double) {
    self.accountNumber = accountNumber
    self.balance = initialDeposit
  }
  func deposit(amount: Double, to account: BankAccount) {
  	assert(amount >= 0)
  	account.balance = account.balance + amount // Actor-isolated property 'balance' can not be referenced from a non-isolated context
  }
}

위의 deposit 메서드를 보면
매개변수로 들어온 account 계좌에 amount 만큼 입금하려는 동작을 하려한다.
하지만 들어온 매개변수인 account 객체 입장에서 deposit 메서드는 non-isolated context이기 때문에 참조할 수 없다.
이걸 해결하기 위해 isolated 키워드가 사용된다.

actor BankAccount {
  let accountNumber: Int
  var balance: Double

  init(accountNumber: Int, initialDeposit: Double) {
    self.accountNumber = accountNumber
    self.balance = initialDeposit
  }
  func deposit(amount: Double, to account: isolated BankAccount) {
  	assert(amount >= 0)
  	account.balance = account.balance + amount
  }
}

매개변수에 isolated를 키워드를 추가해주어 접근이 가능하게 된다.
이 키워드를 사용하면 선언됨 함수가 해당 actor실행 컨텍스트 내에서 실행됨을 보장.

  • 이 때, 함수의 매개변수 중 하나라도 isolated 키워드를 가지면 해당 함수는 actor-isolated가 된다.

⚠️ 다만 isolated를 적용하면 적용한 함수의 주도권이 매개변수에게 넘어가(실행 컨텍스트) self가 외부인이 되어 non-isolated 상태가 되버림.

즉, self를 통한 접근이 불가해짐.

nonisolated

Actor의 멤버(메서드, 프로퍼티)가 Actor의 격리된 상태에 포함되지 않음을 명시

기존 actor 내부의 값에 접근하려면 await 키워드를 이용해 데이터 레이스가 발생하지 않을 시점에 비동기적으로 접근하였으나, 동기적으로 접근 할 수 있게 해줌.

Actor의 보호를 받지 않음을 명시했기 때문

actor Counter {
	var count: Int = 0
	nonisolated let name: String = "hello"
	
	func increase(diffCounter: Counter) {
		self.count += 1
		await diffCounter.decrease()
	}
}

extension Counter {
	func decrease() {
		self.count -= 1
	}
}

//기존
let name = await Counter.name
//nonisolated 적용 시
let name = Counter.name
print(name)
  • await을 사용하지 않고 동기적 접근이 가능해짐.
유의할 점
  • var에 적용 시에는 불가. data-race 문제가 발생할 수 있기 때문에 컴파일 에러 발생

Reentrancy (재진입성)

actor는 내부 메서드가 await과 같은 키워드로 일시 중단될 경우, 다른 작업 처리 가능

actor Counter {
    private var value = 0

    func increment() async {
        value += 1
        await Task.sleep(1_000_000_000) // 1초 대기
        value += 1
    }

    func reset() {
        value = 0
    }
}

Counter 객체가 increment 메서드를 호출하고 중단된 시점동안 reset 메서드 동작 가능

주의할 점

struct Main {
    static func main() async {
        let counter = Counter()

        // increment 실행 중 일시 중단 지점이 있으므로
        // 그 사이에 reset이 호출되면 value 상태가 예상과 달라질 수 있음
        Task {
            await counter.increment()
        }

        // 0.3초 뒤에 reset 호출
        Task {
            try? await Task.sleep(nanoseconds: 300_000_000)
            await counter.reset()
        }

        // 2초 뒤 최종 결과 출력
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        let final = await counter.getValue()
        print("Final value: \(final)")
        
        1? 0?
    }
}

Counter객체 내부의 프로퍼티를 접근하는 여러 메서드를 호출할 경우, data-race 문제가 있음.

@MainActor

DispatchQueue.main에서 작업을 실행하는 excutor를 제공.
메인 Thread에서 실행되어야 하는 작업을 안전하게 처리 가능

클래스, 구조체에 적용

클래스 및 구조체 내부의 모든 메서드 및 프로퍼티가 메인 Thread에 동작하게 적용하는 방법

@MainActor
class ViewModel {
    var title: String = "Hello, Swift!"

    func updateData() {
        // UI 업데이트 코드
    }
}

개별 메서드 및 프로퍼티에 적용

특정 메서드, 프로퍼티가 메인 Thread에 동작하게 적용하는 방법

class ViewModel {
    @MainActor var title: String = "Hello, Swift!"

    @MainActor
    func updateUI() {
        // UI 업데이트 코드
    }
}

클로저에 적용

특정 클로저가 메인 Thread에 동작하게 적용하는 방법

func fetchData(completion: @MainActor @escaping () -> Void) {
    Task {
        await someBackgroundOperation()
        await completion()
    }
}

completion이 메인 Thread에서 동작하도록 적용

특정 코드에만 적용

Task {
    await MainActor.run {
        // 메인 스레드에서 실행할 코드
    }
}

주의사항

  • 기존 GCD, Combine 방식에 적용하더라도 동작 X, 수동으로 메인 스레드로 전환 해주어야 함.(DispatchQueue.main)
  • 동기 context에서 호출 시 메인 Thread가 아닌 곳에서 호출될 수 있음.
상황 1: @MainActor라 선언했음에도 DispatchQueue.global과 같은 스레드를 따로 지정할 경우
@MainActor
class Test {
    var state: Bool = false {
        didSet {
            print(Thread.isMainThread) // false
        }
    }
    var subscriptions: Set<AnyCancellable> = []
    
    init() {
        Just(true)
            .receive(on: DispatchQueue.global(qos: .background))
            .sink {[weak self] _ in
                self?.updateState()
            }.store(in: &subscriptions)
    }
    
    func updateState() {
        print(Thread.isMainThread)  // false
        state.toggle()
    }
}

MainThread에 돌아간다는 것을 적용했어도 Combine코드에서 Thread를 따로 지정할 경우, MainThread에서 돌아감을 보장X

상황 2: @MainActor로 선언된 함수를 동기 함수 내부에서 호출할 경우
@MainActor
func updateUI() {
    print("UI updated on thread: \(Thread.isMainThread ? "Main Thread" : "Background Thread")")
}

// 동기 context에서 호출
func someFunction() {
    updateUI() // ⚠️ 이 코드는 메인 스레드에서 실행된다는 보장이 없음
}

동기 함수는 호출된 스레드에서 즉시 실행되기 때문에 Thread를 지정해주어도 호출된 Thread에서 동작

Swift 6에서 컴파일 할 경우, 해당 부분들은 모두 경고를 통해 확인할 수 있음 (엄격한 동시성 검사 활성화 시)

출처:
https://github.com/swiftlang/swift-evolution/blob/main/proposals/0306-actors.md
https://github.com/swiftlang/swift-evolution/blob/main/proposals/0313-actor-isolation-control.md

https://www.swiftwithvincent.com/blog/discover-how-main-actor-works-in-swift?utm_source=chatgpt.com

https://forums.swift.org/t/using-mainactor-to-ensure-execution-on-the-main-thread/60764

profile
안녕하세요! iOS 개발자입니다!

0개의 댓글