Swift 使用 Process() 和多线程的命令行工具在一定数量的执行轮次后崩溃 (~3148)

Swift Command Line Tool utilizing Process() and multithreading crashes after a certain number of execution rounds (~3148)

我在 Swift 中实现了一个密码生成器脚本,它利用 Process() 来执行 Mac OS X 命令行任务。密码本身只是随机字符串,然后由命令行任务加密 (bcrypt),如下所示:

/usr/sbin/htpasswd -bnBC 10 '' this_is_the_password | /usr/bin/tr -d ':\n'

还使用多个线程并行生成密码及其哈希值。

注意:多线程命令行任务(与其他几个相比本机 Swift 我尝试过的库)在执行时间方面显着提高了性能。

问题

程序 运行 在第一 ~3148 轮中很好,总是 在这个数字附近崩溃(可能与线程数 运行ning)。 例如,如果我配置 2000 密码,代码将按预期终止,没有任何错误。

常见错误消息

BREAKPOINT_1execute(...) 函数的 catch 块中的 Process+Pipe.swift 中设置断点会导致:

Thread 1: signal SIGCHLD
po error.localizedDescription
"The operation couldn\U2019t be completed. (NSPOSIXErrorDomain error 9 - Bad file descriptor)"

当取消注释四个 //return self.hash(string, cost: cost) 代码片段以忽略错误时,以下错误最终导致执行崩溃(同样在 execute(...) 中,但不一定在 catch 块中):

Program stops ...
Thread 32: EXC_BAD_ACCESS (code=2, address=0x700003e6bfd4)
... on manual continue ...
Thread 2: EXC_BAD_ACCESS (code=2, address=0x700007e85fd4)
po process
error: Trying to put the stack in unreadable memory at: 0x700003e6bf40.

代码

相关的代码组件是 Main.swift,它初始化并启动(后来停止)PasswordGenerator,然后循环 n 次以通过nextPassword() 来自 PasswordGeneratorPasswordGenerator 本身利用 execute(...)Process 扩展到 运行 生成散列的命令行任务。

Main.swift

class Main {
  private static func generate(...) {
    ...
    PasswordGenerator.start()
    for _ in 0..<n {
      let nextPassword = PasswordGenerator.nextPassword()
      let readablePassword = nextPassword.readable
      let password = nextPassword.hash
      ...
    }
    PasswordGenerator.stop()
    ...
  }
}

PasswordGenerator.swift

PasswordGenerator 运行 多个线程并行。 nextPassword() 尝试获取密码(如果 passwords 数组中有密码)或者等待 100 秒。

struct PasswordGenerator {
  typealias Password = (readable: String, hash: String)
  
  private static let semaphore = DispatchSemaphore(value: 1)
  private static var active = false
  private static var passwords: [Password] = []
  
  static func nextPassword() -> Password {
    self.semaphore.wait()
    if let password = self.passwords.popLast() {
      self.semaphore.signal()
      return password
    } else {
      self.semaphore.signal()
      sleep(100)
      return self.nextPassword()
    }
  }
  
  static func start(
      numberOfWorkers: UInt = 32,
      passwordLength: UInt = 10,
      cost: UInt = 10
  ) {
      self.active = true
      
      for id in 0..<numberOfWorkers {
          self.runWorker(id: id, passwordLength: passwordLength, cost: cost)
      }
  }
  
  static func stop() {
    self.semaphore.wait()
    self.active = false
    self.semaphore.signal()
  }
  
  private static func runWorker(
    id: UInt,
    passwordLength: UInt = 10,
    cost: UInt = 10
  ) {
    DispatchQueue.global().async {
      var active = true
      repeat {
        // Update active.
        self.semaphore.wait()
        active = self.active
        print("numberOfPasswords: \(self.passwords.count)")
        self.semaphore.signal()
        
        // Generate Password.
        // Important: The bycrypt(cost: ...) step must be done outside the Semaphore!
        let readable = String.random(length: Int(passwordLength))
        let password = Password(readable: readable, hash: Encryption.hash(readable, cost: cost))
        
        // Add Password.
        self.semaphore.wait()
        self.passwords.append(password)
        self.semaphore.signal()
      } while active
    }
  }
}

Encryption.swift

struct Encryption {
  static func hash(_ string: String, cost: UInt = 10) -> String {
    // /usr/sbin/htpasswd -bnBC 10 '' this_is_the_password | /usr/bin/tr -d ':\n'
    
    let command = "/usr/sbin/htpasswd"
    let arguments: [String] = "-bnBC \(cost) '' \(string)".split(separator: " ").map(String.init)
    
    let result1 = Process.execute(
      command: command,//"/usr/sbin/htpasswd",
      arguments: arguments//["-bnBC", "\(cost)", "''", string]
    )
    
    let errorString1 = String(
      data: result1?.error?.fileHandleForReading.readDataToEndOfFile() ?? Data(),
      encoding: String.Encoding.utf8
    ) ?? ""
    guard errorString1.isEmpty else {
//      return self.hash(string, cost: cost)
      fatalError("Error: Command \(command) \(arguments.joined(separator: " ")) failed with error: \(errorString1)")
    }
    
    guard let output1 = result1?.output else {
//    return self.hash(string, cost: cost)
      fatalError("Error: Command \(command) \(arguments.joined(separator: " ")) failed! No output.")
    }
      
    let command2 = "/usr/bin/tr"
    let arguments2: [String] = "-d ':\n'".split(separator: " ").map(String.init)
    
    let result2 = Process.execute(
      command: command2,
      arguments: arguments2,
      standardInput: output1
    )
    
    let errorString2 = String(
      data: result2?.error?.fileHandleForReading.readDataToEndOfFile() ?? Data(),
      encoding: String.Encoding.utf8
    ) ?? ""
    guard errorString2.isEmpty else {
//      return self.hash(string, cost: cost)
      fatalError("Error: Command \(command) \(arguments.joined(separator: " ")) failed with error: \(errorString2)")
    }
    
    guard let output2 = result2?.output else {
//      return self.hash(string, cost: cost)
      fatalError("Error: Command \(command) \(arguments.joined(separator: " ")) failed! No output.")
    }
    
    guard
      let hash = String(
        data: output2.fileHandleForReading.readDataToEndOfFile(),
        encoding: String.Encoding.utf8
      )?.replacingOccurrences(of: "y$", with: "a$")
    else {
      fatalError("Hash: String replacement failed!")
    }
    
    return hash
  }
}

进程+Pipe.swift

extension Process {
  static func execute(
    command: String,
    arguments: [String] = [],
    standardInput: Any? = nil
  ) -> (output: Pipe?, error: Pipe?)? {
    let process = Process()
    process.executableURL = URL(fileURLWithPath: command)
    process.arguments = arguments
    
    let outputPipe = Pipe()
    let errorPipe = Pipe()
    process.standardOutput = outputPipe
    process.standardError = errorPipe
    
    if let standardInput = standardInput {
      process.standardInput = standardInput
    }
    
    do {
      try process.run()
    } catch {
      print(error.localizedDescription)
      // BREAKPOINT_1
      return nil
    }
    
    process.waitUntilExit()
    
    return (output: outputPipe, error: errorPipe)
  }
}

问题

  1. 为什么程序会崩溃?
  2. 为什么像 2000 这样的大密码也不会崩溃?
  3. 多线程实现是否正确?
  4. execute(...)代码有问题吗?

这似乎是一个 Swift 错误。我做了一些测试,只需 运行 很多 Process.run() 就可以重现它。针对 Swift 提出问题:

https://bugs.swift.org/browse/SR-15522

我在研究这个错误时找到了修复方法。似乎,尽管文档声称,Pipe 不会自动关闭其读取文件句柄。

因此,如果您在阅读后添加 try outputPipe.fileHandleForReading.close(),这将解决问题。