DispatchQueue.asyncAfter没有按照我预期的方式工作?

3
我正在开发一个应用程序,调试时遇到了403禁止错误,实际上是由于每分钟对端点的请求量有一个上限导致的(我太傻了)。
好的,我决定将我的网络请求放在DispatchQueue上(这样设计更好并发),并使用asyncAfter(deadline:execute:)函数在每个请求之间造成4秒的延迟。
我的程序设计是列表中的每个项目调用一个函数,在该函数内部将一些工作(即请求)放入该调度队列中。请参见下文:
class ViewController

let serialQueue = DispatchQueue(label: "networkRequests")

func myFirstFunc {
    for item in items {
        self.mySecondFunc(item: item, completionHandler: {(completionItem) in
            // you shouldn't need this
       })
    }
}

func mySecondFunc(item: someType, completionHandler: @escaping (String?) -> Void) {

        let task = session.dataTask(with: request, completionHandler: {(data, response, error) in
            // stuff 
            completionHandler(changedItem)
        })
        self.serialQueue.asyncAfter(deadline: .now() + 4.0) {
                task.resume()
                print(Date())
            }
        }
    }
}

我认为的实现方式是,无论函数是否被不同的线程并发调用,在 asyncAfter(deadline:execute:) 方法中的代码都会被排队,下一个闭包将不会开始执行,直到上一个闭包完成并且另外4秒钟时间已过。
然而,这种方法没有奏效-- print(Date()) 打印的时间之间没有延迟。
我通过使用unix中的sleep()函数来解决了这个问题,但我很想知道如何在Swift中使用GCD来实现。
谢谢!
注:具体来说,我正在寻找一种正确的功能方式,使得单个线程执行每个请求,以便该线程在前一个请求完成后被阻塞,并且在两个请求之间有4秒的延迟。
3个回答

3
这里列出的其他答案是有效的,但它们并不够强大。仅在固定时间间隔内调用task.resume()不能保证请求实际上会在这些间隔内发送。您要看URLSession.shared维护的内部队列的情况。每4秒添加一个任务并不意味着它们一定会在那个时间内发送。它也不能保证请求需要多长时间(考虑网络差的移动网络)。至于Data(contentsOf:),它没有提供任何真正的功能或自定义选项,如错误处理。
更强大的解决方案是使用DispatchGroup,并且只有在前一个请求完成后4秒才启动新的请求。
class ViewController

let serialQueue = DispatchQueue(label: "networkRequests")
let networkGroup = DispatchGroup()

func myFirstFunc {
    for item in items {
        self.mySecondFunc(item: item, completionHandler: {(completionItem) in
            // you shouldn't need this
       })
    }
}

func mySecondFunc(item: someType, completionHandler: @escaping (String?) -> Void) {

        let task = session.dataTask(with: request, completionHandler: {(data, response, error) in
            // stuff 
            completionHandler(changedItem)

            Thread.sleep(forTimeInterval: 4) // Wait for 4 seconds
            networkGroup.leave() // Then leave this block so the next one can run
        })
        self.networkGroup.notify(queue: serialQueue) {
            networkGroup.wait() // Wait for the previous block to finish
            networkGroup.enter() // Enter a new block
            task.resume()
            print(Date())
        }
    }
}

这将确保每个后续请求在前一个请求完成后至少4秒钟后发送,并且不依赖于外部因素(如URLSession的内部队列或网络稳定性)来维护正确的时间,同时不会牺牲URLSession的现代功能。


非常感谢您的帮助,@Garrett。 令我困扰的是调用Thread.sleep会阻塞当前线程。我建议改用DispatchQueue.global().asyncAfter(wallDeadline: DispatchWallTime.now() + 4.0) { self.group.leave() } - Stuart Malone
很好的一点是,并不保证按照特定顺序发出网络调用就意味着它们实际上会按照那个顺序执行。你不仅回答了问题,还解决了问题的根本问题。(投票支持。) - Duncan C

2

想一下你的代码是怎么做的。它通过一个循环遍历数组中的每个项,并针对每个项调用 asyncAfter() 方法。这个 for 循环几乎不需要执行时间,因此每个项都会得到相同的“在4秒后运行”的延迟。(好吧,在长列表中的最后一个可能比第一个晚一微秒。)

如果你想让每个请求在前一个请求启动后4秒运行,你需要增加请求之间的延迟时间:

class ViewController

let serialQueue = DispatchQueue(label: "networkRequests")

func myFirstFunc {
    for (index, item) in items.enumerated {
        self.mySecondFunc(item: item, 
          index: index, 
          completionHandler: {(completionItem) in
            // you shouldn't need this
       })
    }
}

func mySecondFunc(
  item: someType, 
  index: Int,
  completionHandler: @escaping (String?) -> Void) {

        let task = session.dataTask(with: request, completionHandler: {(data, response, error) in
            // stuff 
            completionHandler(changedItem)
        })
        self.serialQueue.asyncAfter(deadline: .now() + 4.0 * index) {
                task.resume()
                print(Date())
            }
        }
    }
}

请注意,您应该真正重构代码,直到上一个任务完成之前不要触发下一个请求,还要跟踪发出每个请求的时间,计算过去一分钟内发出的请求数量,一旦达到阈值,请等待发出下一个请求,直到过去一分钟内发出的总请求数量低于最大值,因为最老的请求已“到期”。

编辑:

重新阅读您的问题和评论后,我更好地理解了您想要做的事情。您试图利用DispatchQueue(label:)初始化程序默认提供串行队列的特性。

问题在于URLSession是异步框架。当您启动下载任务时,它会立即返回。因此,您的任务序列都非常快地完成,但下载任务堆积在URLSession中,并根据其计划运行。

如果您想使用串行队列进行下载,可以使用同步的Data(contentsOf:)初始化程序来同步读取数据:

let serialQueue = DispatchQueue(label: "networkRequests")

func readDataItems {
    for item in items {
        serialQueue.async {
          let data = Data(contentsOf: item.url)
          //Process this data item
          sleep(4)
        }
    }
}

由于 Data(contentsOf:) 函数是同步的,这将导致你的串行队列中的任务在完成之前被阻塞。然后,在继续下一个任务之前,你会使该任务休眠 4 秒。
如我答案的第一部分所述,你应该真正跟踪每个任务完成的时间,并且只有在下载次数/分钟没有超过允许的数量时才开始下载。
你也可以使用操作队列 OperationQueue 来实现上述功能,具有更大的灵活性和控制能力。

这仍然存在严重缺陷。延迟时间需要在前一个请求实际完成后4秒钟。此答案仅确保每个请求在前一个请求启动后4秒钟开始。由于网络问题,这仍可能导致所有请求同时发生。 - rmaddy
是的,我在我的回答中说过了。这回答了问题,但仍不是解决问题的完整方案。 - Duncan C
@rmaddy,如果你感觉有雄心壮志,应该实现一个适当的解决方案,并将其发布为更好的答案。那比我有时间做的工作还要多。 - Duncan C
@Duncan C 感谢您的解释。这让我很有道理。我原以为 asyncAfter 的工作方式是将闭包放在队列中,如果它是串行队列,则不会执行下一个任务,直到上一个任务完成并且过了额外的 4 秒钟。您如何设计它,使得每个 task.resume() 在同一线程上调用 + 等待前一个调用完成,然后再等待额外的 4 秒钟,然后进行调用?对我来说,似乎我正在寻找类似于 syncAfter 的东西,以便线程将被阻塞。但我不确定如何实现所需的功能。 - Meg

0
你的问题在于task.resume()是异步执行的,因此mySecondFunc会退出并且几毫秒后开始处理下一个请求。这些请求会非常接近地发送,首个请求有4秒的初始延迟。
为解决这个问题,在第二个函数中添加延迟参数:
func myFirstFunc {
    for (index, item) in items.enumerated() {
        self.mySecondFunc(item: item, delay: index * 4) {completionItem in
            // do stuffs
        }
    }
}

func mySecondFunc(item: SomeType, delay: TimeInterval = 0, completionHandler: @escaping (String?) -> Void) {
    let task = session.dataTask(with: request) {data, response, error in
        // stuff
        completionHandler(changedItem)
    }
    self.serialQueue.asyncAfter(deadline: .now() + delay) {
        task.resume()
        print(Date())
    }
}

这仍然存在严重缺陷。延迟时间需要在前一个请求实际完成后4秒钟。此答案仅确保每个请求在前一个请求启动后4秒钟开始。由于网络问题,这仍可能导致所有请求同时发生。 - rmaddy
@rmaddy 我同意需求是可以解释的:“在每个请求之间造成4秒的延迟”。我理解为每个请求之间开始相隔4秒,而不是在前一个请求完成后再等待4秒开始下一个请求。 - Code Different

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