Repository: Azure-Samples/cognitive-services-speech-sdk

Issue #2506: [TTS/STT] Dangling threads
Created: 2024-07-17T21:19:48Z
Here is minimal reproduction example ( self contained so I don't have to follow all the instructions, you don't need anyio if you're using the pure_thread version, it's here just because we're using it, so I included it  ). The problem here is that worker threads for MS speech synth ( the recognizer has the same problem ) are left dangling. The machines we're using are Ubuntu 22.04 ( also my machine ). We're using MS speech recog/synth in our system which handles many simultaneous real time audio connections, and we spawn a thread for each of those, and then inside it we run synth/recog . It seems by this data that 5 worker threads are spawned for each synth instance, so to sum it up, it quickly chokes the system.
If there is something I'm missing in how we use the API, please do let me know.
SDK version confirmed: 1.37.0, 1.38.0
import gc
import os
import subprocess
import threading
import time
from threading import Thread

import anyio
import anyio.from_thread
import azure.cognitiveservices.speech as speechsdk

LOG_LEVEL = 0
PURE_THREAD = 1


def log(text, min_level=0):
    if LOG_LEVEL >= min_level:
        print(text)


class NullOutputStream(speechsdk.audio.PushAudioOutputStream):
    def __init__(self):
        super().__init__(self)

    def write(self, data: memoryview) -> int:
        log(f"Write : {len(data)}", 2)
        return len(data)

    def close(self) -> None:
        pass


def get_speech_synth():
    speech_cfg = speechsdk.SpeechConfig(
        subscription=os.environ["SPEECH_KEY"], region=os.environ["SPEECH_REGION"]
    )
    speech_cfg.speech_synthesis_language = "en"
    speech_cfg.speech_synthesis_voice_name = "en-US-AriaNeural"
    speech_cfg.set_speech_synthesis_output_format(
        speechsdk.SpeechSynthesisOutputFormat.Raw16Khz16BitMonoPcm
    )
    return speechsdk.SpeechSynthesizer(
        speech_config=speech_cfg,
        audio_config=speechsdk.AudioConfig(stream=NullOutputStream()),
    )


def disconnect_signals(synth: speechsdk.SpeechSynthesizer):
    synth.synthesis_completed.disconnect_all()
    synth.synthesis_started.disconnect_all()


def thread_process():
    log(f"Thread {threading.current_thread()} started")

    synth = get_speech_synth()

    evt = threading.Event()

    def started(eargs: speechsdk.SpeechSynthesisEventArgs):
        log("Synth started", 1)

    def completed(eargs: speechsdk.SpeechSynthesisEventArgs):
        log("Synth completed", 1)
        evt.set()

    synth.synthesis_started.connect(started)
    synth.synthesis_completed.connect(completed)

    log("Before speaking", 1)
    synth.speak_text_async("Hello how are you")
    log("before sleep", 2)
    time.sleep(10)
    log("after sleep", 1)

    # we're waiting for stop stopped/completed event, thread should shut down?
    synth.stop_speaking_async()

    evt.wait()

    disconnect_signals(synth)

    synth = None
    log("Garbage collecting? maybe that's it?")
    gc.collect()
    log(f"Thread {threading.current_thread()} ended")


async def task_process(portal: anyio.from_thread.BlockingPortal):
    log(f"Thread {threading.current_thread()} started")

    synth = get_speech_synth()
    evt = anyio.Event()

    def started(eargs: speechsdk.SpeechSynthesisEventArgs):
        log("Synth started", 1)

    def completed(eargs: speechsdk.SpeechSynthesisEventArgs):
        log("Synth completed", 1)
        portal.call(evt.set)
    
    synth.synthesis_started.connect(started)
    synth.synthesis_completed.connect(completed)

    log("Before speaking", 1)
    synth.speak_text_async("Hello how are you")
    log("before sleep", 2)
    await anyio.sleep(10)
    log("after sleep", 2)

    # we're waiting for stop stopped/completed event, thread should shut down?
    synth.stop_speaking_async()
    await evt.wait()

    # signals still connected ? they are not
    disconnect_signals(synth)

    synth = None
    log("Garbage collecting? maybe that's it?")
    gc.collect()
    log(f"Thread {threading.current_thread()} ended")


successful_threads = 0


async def with_blocking_portal():
    async with anyio.from_thread.BlockingPortal() as bp:
        await task_process(bp)


def run():
    global successful_threads
    try:
        if PURE_THREAD:
            thread_process()
        else:
            anyio.run(with_blocking_portal)
        successful_threads += 1
    except Exception as e:
        log(f"error {e}", 2)


def print_thread_state():
    log("THREAD STATE")
    threads = subprocess.check_output(
        ["ps", "-T", "-p", str(os.getpid())]
    ).splitlines()[1::]
    log(f"Successfully executed {successful_threads}")
    log(f"Running count {len(threads)}")
    log("Use gdb to look at dangling microsoft threads.")
    log("END THREAD STATE")


threads = [Thread(target=run) for i in range(10)]

print_thread_state()

for t in threads:
    t.start()

for t in threads:
    t.join()

log("garbage collecting")
gc.collect()
log("finished all")
print_thread_state()
time.sleep(50000)


Here is example of the output:
THREAD STATE
Successfully executed 0
Running count 1
Use gdb to look at dangling microsoft threads.
END THREAD STATE
Thread <Thread(Thread-1 (run), started 123425957570112)> started
Thread <Thread(Thread-2 (run), started 123425949177408)> started
Thread <Thread(Thread-3 (run), started 123425940784704)> started
Thread <Thread(Thread-4 (run), started 123425932392000)> started
Thread <Thread(Thread-5 (run), started 123425923999296)> started
Thread <Thread(Thread-6 (run), started 123425915606592)> started
Thread <Thread(Thread-7 (run), started 123425907213888)> started
Thread <Thread(Thread-8 (run), started 123425898821184)> started
Thread <Thread(Thread-9 (run), started 123425414706752)> started
Thread <Thread(Thread-10 (run), started 123424928159296)> started
Garbage collecting? maybe that's it?
Thread <Thread(Thread-4 (run), started 123425932392000)> ended
Garbage collecting? maybe that's it?
Garbage collecting? maybe that's it?
Thread <Thread(Thread-9 (run), started 123425414706752)> ended
Garbage collecting? maybe that's it?
Thread <Thread(Thread-1 (run), started 123425957570112)> ended
Garbage collecting? maybe that's it?
Garbage collecting? maybe that's it?
Garbage collecting? maybe that's it?
Thread <Thread(Thread-3 (run), started 123425940784704)> ended
Garbage collecting? maybe that's it?
Thread <Thread(Thread-6 (run), started 123425915606592)> ended
Thread <Thread(Thread-2 (run), started 123425949177408)> ended
Garbage collecting? maybe that's it?
Thread <Thread(Thread-7 (run), started 123425907213888)> ended
Thread <Thread(Thread-10 (run), started 123424928159296)> ended
Garbage collecting? maybe that's it?
Thread <Thread(Thread-8 (run), started 123425898821184)> ended
Thread <Thread(Thread-5 (run), started 123425923999296)> ended
garbage collecting
finished all
THREAD STATE
Successfully executed 10
Running count 51
Use gdb to look at dangling microsoft threads.
END THREAD STATE

Duplicate issue #2545: Threads left hanging after synthesizer is deleted
Created: 2024-08-20T06:38:18Z
Speech SDK starts some native thread when speech synthesis is requested. These threads are kept alive even after synthesizer is deleted and garbage collected.
To Reproduce
Steps to reproduce the behavior:

Run the following code:

import os
import subprocess
import azure.cognitiveservices.speech as speechsdk
import threading


def print_thread_count(msg: str = ""):
    sys_th_count = len(subprocess.check_output(["ps", "-T", "-p", str(os.getpid())]).splitlines())
    print(msg, "- active python threads:", threading.active_count(), "; system thread count: ", sys_th_count)

class WriterCallback(speechsdk.audio.PushAudioOutputStreamCallback):
    def write(self, buffer: memoryview) -> int:
        return len(buffer)

    def close(self) -> None:
        pass


def create_synthesizer():
    compl_evt = threading.Event()

    def on_completed(eargs: speechsdk.SpeechSynthesisEventArgs):
        compl_evt.set()

    # This example requires environment variables named "SPEECH_KEY" and "SPEECH_REGION"
    speech_config = speechsdk.SpeechConfig(subscription=os.environ.get('SPEECH_KEY'), region=os.environ.get('SPEECH_REGION'))
    audio_config = speechsdk.audio.AudioOutputConfig(stream=speechsdk.audio.PushAudioOutputStream(WriterCallback()))
    audio_config = speechsdk.audio.AudioOutputConfig(use_default_speaker=True)

    # The neural multilingual voice can speak different languages based on the input text.
    speech_config.speech_synthesis_voice_name='en-US-AvaMultilingualNeural'

    speech_synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=audio_config)

    speech_synthesizer.synthesis_completed.connect(on_completed)

    return speech_synthesizer, compl_evt



speech_synthesizer, compl_evt = create_synthesizer()
def do_speech() -> bool:

    print_thread_count("before input")
    print("Enter some text that you want to speak >")
    text = input()

    if text == "":
        print("Exit")
        return False

    print_thread_count("before speak")
    result_future = speech_synthesizer.speak_text_async(text)
    print_thread_count("after speak")

    compl_evt.wait()
    compl_evt.clear()
    print_thread_count("after wait")

    # Uncomment this to fix the issue.
    # result_future.get()


    return True

while do_speech():
    print_thread_count("loop...")

print_thread_count("Cleanup...")
del speech_synthesizer
print_thread_count("After del")
# At this point number of threads will be about +6 higher then before the while loop,
# unless result_future.get is called for each result_future.

Observe that the number of native threads did not decrease at the end of the script (just press enter to exit the script).
Ucomment # result_future.get() and run again.
Observe that the number of native threads did decrease to previous value at the end of the script.

Similar problem can be observed with recognizer too.

Closing comment (2024-08-20T18:05:41Z): Duplicate of #2506