How to avoid data race causing EXC_BAD_ACCESS when using DispatchQueue.global to set sound effect 'timer'?

Viewed 38

This question was revised significantly to narrow its scope upon identifying a more fundamental issue with the code sample. The previous version contained a discussion of multiple AVAudioPlayer related errors, while this one focuses on the error in the title.

The other errors were mostly resolved in the Community Wiki answer below, and there were no answers or comments by other users at the time of this edit, so I've taken the opportunity to improve the focus of this question by narrowing its scope and updating the code sample to better reflect the underlying issue.


In attempting to play sounds interactively in fast succession, I have encountered a recurring issue with data race conflicts resulting from multiple threads trying to access the same variable. This occurs when attempting to update a status boolean (representing whether an instance of AVAudioPlayer has finished playing a sound) from a global DispatchQueue. This answer correctly pointed out the cause of the error, and applying Thread Sanitizer confirmed it (the error traceback is included at the bottom of the question).

Declaring singleton queues instead of creating a new queue every time playEffect is called did not fix the problem. How can the 'timer' process for resetting sound effect availability be made thread safe, to avoid any possibility of simultaneous accesses? This is the part of the code which is causing the error:

audioPlayers[fileName]?.1.asyncAfter(deadline: .now() + (audioPlayers[fileName]?.0 ?? 1.39)) {
    audioPlayers[fileName]?.2[playerIndex].1 = true
    print("Reset audio player #\(playerIndex) for sound effect \(fileName) status to Available")
}

Below is the MCVE and the error traceback:

AppDelegate didFinishLaunchingWithOptions

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Send this to the utility queue, to avoid stalling the app launch if it takes a long time
        DispatchQueue.global(qos: .utility).async {
            do {
                let emptySoundFileURL = Bundle.main.url(forResource: "Empty", withExtension: "m4a")
                ErrorGenerator.AVInitializer = try AVAudioPlayer(contentsOf: emptySoundFileURL!)
                ErrorGenerator.AVInitializer?.play()
                ErrorGenerator.soundIsReady = true
                print("SOUND IS READY!")
            }
            catch {
                print("Error initializing AVAudioPlayer: \(error)")
            }
        }
        return true
    }

ViewController file

import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var tappableView: UIView!
    
    let colors = [UIColor.systemGreen, UIColor.systemPink]
    var colorIndex = true
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }

    
    @IBAction func viewTapped(_ sender: UITapGestureRecognizer) {
        // Shows that the square view has recognized a tap by toggling its color
        colorIndex.toggle()
        tappableView.backgroundColor = colors[Int(truncating: NSNumber(value: colorIndex))]
        
        // Call the sound generating function from the ErrorGenerator struct
        ErrorGenerator.playTapSounds()
    }
}

ErrorGenerator file

import Foundation
import AVFoundation

struct ErrorGenerator {
    // Will be set inside AppDelegate, as soon as the app launches
    static var AVInitializer: AVAudioPlayer?
    static var soundIsReady = false
    // Instead of generating a new queue every time a sound is played, define 4 singleton queues and only use these to avoid collisions, as discussed in this answer by matt:  https://stackoverflow.com/a/68250594/18248018
    static let queue1 = DispatchQueue(label: "Sound 1 Queue", qos: .utility)
    static let queue2 = DispatchQueue(label: "Sound 2 Queue", qos: .utility)
    static let queue3 = DispatchQueue(label: "Sound 3 Queue", qos: .utility)
    static let queue4 = DispatchQueue(label: "Sound 4 Queue", qos: .utility)
    
    // EACH SOUND EFFECT HAS 3 ASSOCIATED AVAUDIOPLAYER INSTANCES, TO ALLOW IT TO BE PLAYED SIMULTANEOUSLY UP TO 3 TIMES.
    // Dictionary keys are the sound effect names.
    // Values are tuples containing 0. as length of the sound effect in seconds, 1. as the .utility queue associated with the sound effect, and 2. as an array of 3 inner tuples.
    // Inner tuples have 0. as an AVAudioPlayer instance, and 1. as a boolean representing whether the instance has finished playing/is ready to play its corresponding sound effect.
    static var audioPlayers: [String: (Double, DispatchQueue, [(AVAudioPlayer?, Bool)])] = [
        "Sound 1": (1.39, queue1, [(nil, true), (nil, true), (nil, true)]),
        "Sound 2": (1.1, queue2, [(nil, true), (nil, true), (nil, true)]),
        "Sound 3": (1.28, queue3, [(nil, true), (nil, true), (nil, true)]),
        "Sound 4": (1.06, queue4, [(nil, true), (nil, true), (nil, true)])
    ]
    
    // The sound effect name will be provided by the random number generator when this function is called inside playTapSounds
    static func playEffect(named fileName: String) {
        if soundIsReady {
            let playerIndex = soundEffectIsAvailableAtIndex(named: fileName)
            // Don't attempt to play the sound effect unless there is an available AVAudioPlayer for it.
            // This caps the number of simultaneous threads that can be generated by this function at 12.
            if playerIndex != -1 {
                do {
                    let soundFileURL = Bundle.main.url(forResource: fileName, withExtension: "m4a")
                    audioPlayers[fileName]?.2[playerIndex].0 = try AVAudioPlayer(contentsOf: soundFileURL!)
                    // Shows that this audio player is busy - do not attempt to reuse it until it has finished playing the sound effect
                    audioPlayers[fileName]?.2[playerIndex].1 = false
                    audioPlayers[fileName]?.2[playerIndex].0?.play()
                    print("Playing sound effect \(fileName) on audio player #\(playerIndex)")
                    
                    // Reset the audio player's availability status after the sound has finished playing.  The default value is the length of the longest sound effect (in seconds).
                    // Use the correct one of the 4 queues defined above.  Do not generate new queues here, as that was the source of the EXC_BAD_ACCESS errors.
                    audioPlayers[fileName]?.1.asyncAfter(deadline: .now() + (audioPlayers[fileName]?.0 ?? 1.39)) {
                        audioPlayers[fileName]?.2[playerIndex].1 = true
                        print("Reset audio player #\(playerIndex) for sound effect \(fileName) status to Available")
                    }
                }
                catch {
                    print("Error playing sound effect \(fileName): \(error)")
                }
            }
            
        } else {
            print("Sound effect capability has not yet been initialized by AppDelegate")
        }
    }
    
    // Returns the first index (possibilities are 0, 1 or 2) of an available (i.e. associated boolean value is true) sound effect in the audioPlayers dict.  Returns -1 if none are available.
    static func soundEffectIsAvailableAtIndex(named fileName: String) -> Int {
        var index = -1
        if let dictValue = audioPlayers[fileName] {
            for i in 0..<dictValue.2.count {
                if dictValue.2[i].1 {
                    index = i
                    break
                }
            }
        }
        return index
    }
    
    static func playTapSounds() {
        let s = "Sound \(Int.random(in: 1...4))"
        playEffect(named: s)
    }
}

Error messages from Xcode console

Sound Errors(40921,0x104b20580) malloc: nano zone abandoned due to inability to preallocate reserved vm space.
2022-09-22 14:19:03.911423-0500 Sound Errors[40921:2942859]  DataSource read failed
2022-09-22 14:19:04.153477-0500 Sound Errors[40921:2942865] [plugin] AddInstanceForFactory: No factory registered for id <CFUUID 0x112351560> F8BB1C28-BAE8-11D6-9C31-00039315CD46
SOUND IS READY!
2022-09-22 14:19:07.614898-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 3 on audio player #0
2022-09-22 14:19:08.740789-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 4 on audio player #0
2022-09-22 14:19:08.939415-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 1 on audio player #0
Reset audio player #0 for sound effect Sound 3 status to Available
2022-09-22 14:19:09.089194-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 3 on audio player #0
2022-09-22 14:19:09.256983-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 2 on audio player #0
2022-09-22 14:19:09.406033-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 3 on audio player #1
2022-09-22 14:19:09.540224-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 2 on audio player #1
2022-09-22 14:19:09.823113-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 2 on audio player #2
Reset audio player #0 for sound effect Sound 4 status to Available
2022-09-22 14:19:09.990802-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 4 on audio player #0
2022-09-22 14:19:10.158240-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 1 on audio player #1
2022-09-22 14:19:10.310215-0500 Sound Errors[40921:2940418]  DataSource read failed
Playing sound effect Sound 1 on audio player #2
Reset audio player #0 for sound effect Sound 1 status to Available
Reset audio player #0 for sound effect Sound 2 status to Available
2022-09-22 14:19:10.474106-0500 Sound Errors[40921:2940418]  DataSource read failed
==================
WARNING: ThreadSanitizer: Swift access race (pid=40921)
  Modifying access of Swift variable at 0x000104869bd0 by thread T6:
    #0 closure #1 in static ErrorGenerator.playEffect(named:) ErrorOrigin.swift:50 (Sound Errors:arm64+0x10000dc90)
    #1 partial apply for closure #1 in static ErrorGenerator.playEffect(named:) <compiler-generated> (Sound Errors:arm64+0x10000e118)
    #2 thunk for @escaping @callee_guaranteed () -> () <compiler-generated> (Sound Errors:arm64+0x100008620)
    #3 __tsan::invoke_and_release_block(void*) <null>:81817444 (libclang_rt.tsan_iossim_dynamic.dylib:arm64+0x7b690)
    #4 _dispatch_client_callout <null>:81817444 (libdispatch.dylib:arm64+0x5d58)

  Previous modifying access of Swift variable at 0x000104869bd0 by thread T8:
    #0 closure #1 in static ErrorGenerator.playEffect(named:) ErrorOrigin.swift:50 (Sound Errors:arm64+0x10000dc90)
    #1 partial apply for closure #1 in static ErrorGenerator.playEffect(named:) <compiler-generated> (Sound Errors:arm64+0x10000e118)
    #2 thunk for @escaping @callee_guaranteed () -> () <compiler-generated> (Sound Errors:arm64+0x100008620)
    #3 __tsan::invoke_and_release_block(void*) <null>:81817444 (libclang_rt.tsan_iossim_dynamic.dylib:arm64+0x7b690)
    #4 _dispatch_client_callout <null>:81817444 (libdispatch.dylib:arm64+0x5d58)

  Location is global 'static ErrorGenerator.audioPlayers' at 0x000104869bd0 (Sound Errors+0x100019bd0)

  Thread T6 (tid=2942864, running) is a GCD worker thread

  Thread T8 (tid=2942865, running) is a GCD worker thread

SUMMARY: ThreadSanitizer: Swift access race ErrorOrigin.swift:50 in closure #1 in static ErrorGenerator.playEffect(named:)
==================
ThreadSanitizer report breakpoint hit. Use 'thread info -s' to get extended information about the report.
Reset audio player #0 for sound effect Sound 3 status to Available
Reset audio player #0 for sound effect Sound 4 status to Available
Reset audio player #1 for sound effect Sound 3 status to Available
2022-09-22 14:19:11.462591-0500 Sound Errors[40921:2942865] -[NSTaggedPointerString count]: unrecognized selector sent to instance 0x8000000000000000
Playing sound effect Sound 1 on audio player #0
2022-09-22 14:19:11.473913-0500 Sound Errors[40921:2940418] [SystemGestureGate] <0x111f0b900> Gesture: System gesture gate timed out.
2022-09-22 14:19:11.475033-0500 Sound Errors[40921:2940418]  DataSource read failed
2022-09-22 14:19:11.501216-0500 Sound Errors[40921:2942865] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[NSTaggedPointerString count]: unrecognized selector sent to instance 0x8000000000000000'
*** First throw call stack:
(
    0   CoreFoundation                      0x000000018040c304 __exceptionPreprocess + 172
    1   libobjc.A.dylib                     0x000000018005131c objc_exception_throw + 56
    2   CoreFoundation                      0x000000018041afbc +[NSObject(NSObject) instanceMethodSignatureForSelector:] + 0
    3   CoreFoundation                      0x0000000180410204 ___forwarding___ + 1308
    4   CoreFoundation                      0x000000018041268c _CF_forwarding_prep_0 + 92
    5   libswiftCore.dylib                  0x000000018bcb611c $sSD8_VariantVyq_SgxciM + 164
    6   libswiftCore.dylib                  0x000000018bcb6000 $sSDyq_SgxciM + 132
    7   Sound Errors                        0x000000010485dcb0 $s12Sound_Errors14ErrorGeneratorV10playEffect5namedySS_tFZyycfU_ + 280
    8   Sound Errors                        0x000000010485e11c $s12Sound_Errors14ErrorGeneratorV10playEffect5namedySS_tFZyycfU_TA + 36
    9   Sound Errors                        0x0000000104858624 $sIeg_IeyB_TR + 96
    10  libclang_rt.tsan_iossim_dynamic.dyl 0x0000000104e77694 _ZN6__tsanL24invoke_and_release_blockEPv + 24
    11  libclang_rt.tsan_iossim_dynamic.dyl 0x0000000104e774f0 _ZN6__tsanL22dispatch_callback_wrapEPv + 664
    12  libdispatch.dylib                   0x0000000104d55d5c _dispatch_client_callout + 16
    13  libdispatch.dylib                   0x0000000104d59210 _dispatch_continuation_pop + 756
    14  libdispatch.dylib                   0x0000000104d708c4 _dispatch_source_invoke + 1684
    15  libdispatch.dylib                   0x0000000104d5ddfc _dispatch_lane_serial_drain + 348
    16  libdispatch.dylib                   0x0000000104d5ed80 _dispatch_lane_invoke + 428
    17  libdispatch.dylib                   0x0000000104d6cb40 _dispatch_workloop_worker_thread + 1720
    18  libsystem_pthread.dylib             0x00000001ae55eb40 _pthread_wqthread + 284
    19  libsystem_pthread.dylib             0x00000001ae55d904 start_wqthread + 8
)
libc++abi: terminating with uncaught exception of type NSException
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[NSTaggedPointerString count]: unrecognized selector sent to instance 0x8000000000000000'
terminating with uncaught exception of type NSException
CoreSimulator 857.7 - Device: iPhone 12 (D510B935-5295-43DC-AC72-E779D87B1D64) - Runtime: iOS 16.0 (20A360) - DeviceType: iPhone 12
1 Answers

UPDATE -- Fixed Slow Start, No Factory Registered and Timed Out errors

After some further experimentation with the code from the question, I came up with a solution to 3 of the issues. Moving the AVAudioPlayer initialization to AppDelegate didFinishLaunchingWithOptions seems to have solved the slow loading error. (I still wrapped the initialization inside a call to the utility queue, to avoid any delays causing the app to fail to launch.)

In order to allow simultaneous playback of multiple instances of the same sound effect, I associated 3 AVAudioPlayer instances with each sound effect.

The sounds now play smoothly when clicking the square, fast or slow, with minimal waiting (the only delay now is when all 3 'slots' for a given effect are taken, and this is the code working as intended). However, the most serious issue is still present: the EXC_BAD_ACCESS.

On both trials, this occurred on the same line: the resetting of the boolean to true inside the call to the global utility queue, inside playEffect. In addition, a random 'noisy' noise played at this point, which was not any of my sound effect files, which leads me to believe that after the error occurs, 'garbage data' is being accessed from an incorrect memory location, instead of the sound effect from the file. The audio was also glitched when I captured a screenshot when the app was still running on the simulator. Since the screenshot sound effect has nothing to do with my app or the simulator, it seems like the bad memory access problems even extend outside of the app itself, for as long as it is kept running.

Here is the updated code example:

AppDelegate didFinishLaunchingWithOptions:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Send this to the utility queue, to avoid stalling the app launch if it takes a long time
        DispatchQueue.global(qos: .utility).async {
            do {
                let emptySoundFileURL = Bundle.main.url(forResource: "Empty", withExtension: "m4a")
                ErrorGenerator.AVInitializer = try AVAudioPlayer(contentsOf: emptySoundFileURL!)
                ErrorGenerator.AVInitializer?.play()
                ErrorGenerator.soundIsReady = true
                print("SOUND IS READY!")
            }
            catch {
                print("Error initializing AVAudioPlayer: \(error)")
            }
        }
        return true
    }

ErrorGenerator:

import Foundation
import AVFoundation

struct ErrorGenerator {
    // Will be set inside AppDelegate, as soon as the app launches
    static var AVInitializer: AVAudioPlayer?
    static var soundIsReady = false
    
    // EACH SOUND EFFECT HAS 3 ASSOCIATED AVAUDIOPLAYER INSTANCES, TO ALLOW IT TO BE PLAYED SIMULTANEOUSLY UP TO 3 TIMES.
    // Dictionary keys are the sound effect names.
    // Values are tuples containing 0. as length of the sound effect in seconds, and 1. as an array of 3 inner tuples.
    // Inner tuples have 0. as an AVAudioPlayer instance, and 1. as a boolean representing whether the instance has finished playing/is ready to play its corresponding sound effect
    static var audioPlayers: [String: (Double, [(AVAudioPlayer?, Bool)])] = [
        "Sound 1": (1.39, [(nil, true), (nil, true), (nil, true)]),
        "Sound 2": (1.1, [(nil, true), (nil, true), (nil, true)]),
        "Sound 3": (1.28, [(nil, true), (nil, true), (nil, true)]),
        "Sound 4": (1.06, [(nil, true), (nil, true), (nil, true)])
    ]
    
    // The sound effect name will be provided by the random number generator when this function is called inside playTapSounds
    static func playEffect(named fileName: String) {
        if soundIsReady {
            let playerIndex = soundEffectIsAvailableAtIndex(named: fileName)
            // Don't attempt to play the sound effect unless there is an available AVAudioPlayer for it.
            // This caps the number of simultaneous threads that can be generated by this function at 12.
            if playerIndex != -1 {
                do {
                    let soundFileURL = Bundle.main.url(forResource: fileName, withExtension: "m4a")
                    audioPlayers[fileName]?.1[playerIndex].0 = try AVAudioPlayer(contentsOf: soundFileURL!)
                    // Shows that this audio player is busy - do not attempt to reuse it until it has finished playing the sound effect
                    audioPlayers[fileName]?.1[playerIndex].1 = false
                    audioPlayers[fileName]?.1[playerIndex].0?.play()
                    print("Playing sound effect \(fileName) on audio player #\(playerIndex)")
                    
                    // Reset the audio player's availability status after the sound has finished playing.  The default value is the length of the longest sound effect (in seconds).
                    DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + (audioPlayers[fileName]?.0 ?? 1.39)) {
                        audioPlayers[fileName]?.1[playerIndex].1 = true
                        print("Reset audio player #\(playerIndex) for sound effect \(fileName) status to Available")
                    }
                }
                catch {
                    print("Error playing sound effect \(fileName): \(error)")
                }
            }
            
        } else {
            print("Sound effect capability has not yet been initialized by AppDelegate")
        }
    }
    
    // Returns the first index (possibilities are 0, 1 or 2) of an available (i.e. associated boolean value is true) sound effect in the audioPlayers dict.  Returns -1 if none are available.
    static func soundEffectIsAvailableAtIndex(named fileName: String) -> Int {
        var index = -1
        if let dictValue = audioPlayers[fileName] {
            for i in 0..<dictValue.1.count {
                if dictValue.1[i].1 {
                    index = i
                    break
                }
            }
        }
        return index
    }
    
    static func playTapSounds() {
        let s = "Sound \(Int.random(in: 1...4))"
        playEffect(named: s)
    }
}

ViewController:

import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var tappableView: UIView!
    
    let colors = [UIColor.systemGreen, UIColor.systemPink]
    var colorIndex = true
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }

    
    @IBAction func viewTapped(_ sender: UITapGestureRecognizer) {
        // Shows that the square view has recognized a tap by toggling its color
        colorIndex.toggle()
        tappableView.backgroundColor = colors[Int(truncating: NSNumber(value: colorIndex))]
        
        // Call the sound generating function from the ErrorGenerator struct
        ErrorGenerator.playTapSounds()
    }
}

And the screenshot showing which line causes the bad access errors: error image

Related