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 내부 프로퍼티는 무조건 self로 접근되어야 함.
	}
}
  • 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가 된다.

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개의 댓글