如果数组是值类型并且因此会被复制,那么它们如何不是线程安全的?

15

阅读这篇文章后,我了解到:

值类型的实例不是共享的:每个线程都有自己的副本。这意味着每个线程都可以读取和写入其实例,而不必担心其他线程在做什么。

然后我看到了这篇回答及其评论,并被告知:

一个数组本身不是线程安全的,如果从多个线程访问它,则所有交互必须进行同步。

关于每个线程都有自己的副本,我被告知:

如果一个线程正在更新一个数组(可能是为了让您可以从另一个队列中看到该编辑),那就根本不适用。

为什么不适用?

我最初认为所有这些都发生在将数组(即值类型)包装到类中,但令我惊讶的是,我被告知这是错误的!所以我又回到了Swift 101 :D


1
我强烈建议阅读这个其他答案值类型的线程安全性 - mfaani
3个回答

35

根本问题在于对“每个线程都有自己的副本”的解释。

是的,我们通常使用值类型来确保线程安全,为每个线程提供对象(如数组)的自己的副本。但这并不意味着值类型可以保证每个线程都会获得自己的副本。

具体而言,使用闭包,多个线程可以尝试改变同一个值类型对象。以下是一段代码示例,展示了一些与 Swift Array 值类型交互的非线程安全代码:

let queue = DispatchQueue.global()

var employees = ["Bill", "Bob", "Joe"]

queue.async {
    let count = employees.count
    for index in 0 ..< count {
        print("\(employees[index])")
        Thread.sleep(forTimeInterval: 1)
    }
}

queue.async { 
    Thread.sleep(forTimeInterval: 0.5)
    employees.remove(at: 0)
}

通常不会添加sleep调用;我仅添加它们以显现那些难以重现的竞态条件。在没有同步的情况下同时从多个线程对一个对象进行修改也是不安全的,但我这样做是为了说明问题。

在这些async调用中,你仍然引用了之前定义的同一个employees数组。因此,在这个特定的例子中,我们将看到输出的结果是“Bill”,跳过了“Bob”(即使是“Bill”被删除),输出了“Joe”(现在是第二项),然后会崩溃,因为尝试访问只剩下两个项目的数组的第三个项目。

现在,我所阐述的只是单一值类型可以在被另一个线程使用时被一个线程修改,从而违反了线程安全性。当编写不安全的代码时,实际上有一系列更基本的问题可能会显现出来,但以上仅仅是一个略微牵强的例子。

但是,你可以通过为第一个async调用添加"capture list"来确保这个单独的线程获得自己的employees数组的副本,以表明你希望使用原始employees数组的副本:

queue.async { [employees] in
    ...
}

或者,如果您将此值类型作为参数传递给另一个方法,则会自动获得此行为:

doSomethingAsynchronous(with: employees) { result in
    ...
}

在这两种情况下,您将享受值语义并看到原始数组的副本(或写时复制),尽管原始数组可能已经在其他地方被改变。

总之,我的观点仅是价值类型不保证每个线程都有自己的副本。 Array 类型不是(也没有许多其他可变值类型)线程安全的。但是,像所有值类型一样,Swift提供了简单的机制(其中一些完全自动和透明),可以为每个线程提供自己的副本,从而更轻松地编写线程安全的代码。


以下是另一个使用另一个值类型的示例,使问题更加明显。以下是一个未能编写线程安全代码返回语义无效对象的示例:

let queue = DispatchQueue.global()

struct Person {
    var firstName: String
    var lastName: String
}

var person = Person(firstName: "Rob", lastName: "Ryan")

queue.async {
    Thread.sleep(forTimeInterval: 0.5)
    print("1: \(person)")
}

queue.async { 
    person.firstName = "Rachel"
    Thread.sleep(forTimeInterval: 1)
    person.lastName = "Moore"
    print("2: \(person)")
}
在这个示例中,第一个打印语句将显示实际上是 "Rachel Ryan",既不是 "Rob Ryan" 也不是 "Rachel Moore"。简言之,我们在检查我们的 Person 处于内部不一致状态时。但是,我们可以再次使用捕获列表来享受值语义:
queue.async { [person] in
    Thread.sleep(forTimeInterval: 0.5)
    print("1: \(person)")
}

在这种情况下,它将显示“Rob Ryan”,而忽略了原始Person可能正在被另一个线程改变的事实。(显然,仅仅通过在第一个async调用中使用值语义并不能真正解决问题,但在第二个async调用中同步或者也使用值语义是必须的。)


嗯,简单来说,物品的所有权可能会因为它是一个引用类型(或者被封装成一个)或多线程问题而变得棘手。在这里,你演示了多线程方面的问题...如果被复制或捕获,事情可能会有所不同... - mfaani
2
我可能会重新表述一下,因为这个问题只在处理多线程代码时才真正显现出来:“编写多线程代码时,您要么应该同步访问相关对象,要么在使用值类型时采用值语义,以确保一个线程中的本地对象副本不会同时被另一个线程改变。” - Rob
@Rob,“当编写的代码不是线程安全时,实际上会出现一系列更基本的问题。” - 我只是想吸收更多的知识,您能否详细说明并列出问题清单,以便我们自己深入探讨?这将更有价值,因为它将从经验中说话。虽然可能与主题无关,但非常感谢您的回复! - Jan
1
@Ian - 在上面的例子中,它处于内部不一致的状态,但没有造成任何损坏。在其他情况下,例如对数组进行未同步的追加操作,您可能会遇到数据丢失(其中某些项仅丢失)或更糟糕的是崩溃,例如“EXC_BAD_ACCESS(code = EXC_I386_GPFLT)”。我建议您查看线程Sanitizer(TSAN),它将帮助您识别各种不同的问题。文档列出了它所识别的线程安全问题的类型。 - Rob
苹果已经破坏了上述链接。但请参见https://developer.apple.com/documentation/xcode/diagnosing_memory_thread_and_crash_issues_early/。 - Rob

2

由于Array是值类型,您可以确保它只有一个直接的所有者。

问题出现在当数组有多个间接的所有者时。考虑以下示例:

Class Foo {
    let array = [Int]()

    func fillIfArrayIsEmpty() {
        guard array.isEmpty else { return }
        array += [Int](1...10)
    }
}

let foo = Foo();

doSomethingOnThread1 {
    foo.fillIfArrayIsEmpty()
}

doSomethingOnThread2 {
    foo.fillIfArrayIsEmpty()
}
array只有一个直接的所有者:它所包含的foo实例。然而,线程1和2都拥有foo的所有权,间接地也拥有其中的array。这意味着它们可以异步地对其进行修改,因此可能会发生竞态条件。
下面是一个可能发生的示例:
  • 线程1开始运行

  • array.isEmpty计算结果为false,保护通过,并且执行将继续通过它

  • 线程1已经用完了它的CPU时间,所以它被从CPU中踢出。操作系统调度线程2

  • 现在正在运行线程2

  • array.isEmpty计算结果为false,保护通过,并且执行将继续通过它

  • 执行array += [Int](1...10)。现在,array等于[1, 2, 3, 4, 5, 6, 7, 8, 9]

  • 线程2完成,放弃CPU,操作系统调度线程1

  • 线程1恢复到上次离开的位置。

  • 执行array += [Int](1...10)。现在,array等于[1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9]。这本不该发生的!


1
@Honey,我会在我的回答中举个例子,但是竞态条件已经被其他资源很好地解释了,所以我不想花太多时间详细讲解。 - Alexander
它的CPU时间,所以它被启动了,你的意思是上下文切换,所以CPU转移到线程2,然后再转回Thread1,然后再转到线程2,结果可能像[1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9]这样混乱,甚至更加混乱。|| 非常感谢您的答案Alex。如果我可以标记两个答案,我会这么做的。 - mfaani
是的,上下文切换。你可以标记其中更好的答案 ;) - Alexander
1
这将是一个升序列表,其中每个数字要么有1个,要么有2个。 - Alexander
让我们在聊天室继续这个讨论 - mfaani
显示剩余2条评论

1
你有一个错误的假设。你认为无论你如何使用结构体,复制总是会自动发生。这是不正确的。如果你复制它们,它们将被简单地复制。
class someClass{ 
var anArray : Array = [1,2,3,4,5]

func copy{
var copiedArray = anArray // manipulating copiedArray & anArray at the same time will NEVER create a problem
} 

func myRead(_ index : Int){
print(anArray[index])
}

func myWrite(_ item : Int){
anArray.append(item)
}
}    

然而,在您的读写函数中,您正在访问anArray——而没有复制它,因此如果同时调用myRead和myWrite函数,则可能会发生竞态条件。您必须通过使用队列来解决(请参见此处)该问题。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接