Skip to content

Thread retention issue on Windows Directshow #2402

Description

@hit-labvn

Thread retention after calling PyAV’s container.close() is consistently reproducible.

  • Windows (dshow): The native thread count climbs across open/read/close cycles.
  • Ubuntu (v4l2): The same loop serves as the control. The thread count stays flat.

Even after a while, the stale threads do not clean themselves up. I have also tried running the garbage collector manually, but it does not help.

I have also run the script on two machines (Windows and Ubuntu) to collect the logs. Please see below.

Tested on both latest PyAV as well as PyAV 17.*

Windows log:

av=18.1.0
ffmpeg=8.1.2
platform=win32
backend=dshow
device='Integrated Webcam'
path='video=Integrated Webcam'
options={'rtbufsize': '256M'}
start threads=4
1 frames=30 threads=15
2 frames=30 threads=19
3 frames=30 threads=22
4 frames=30 threads=25
5 frames=30 threads=28
6 frames=30 threads=31
7 frames=30 threads=34
8 frames=30 threads=37
9 frames=30 threads=40
10 frames=30 threads=43
11 frames=30 threads=46
12 frames=30 threads=49
13 frames=30 threads=49
14 frames=30 threads=52
15 frames=30 threads=55
16 frames=30 threads=58
17 frames=30 threads=61
18 frames=30 threads=64
19 frames=30 threads=67
20 frames=30 threads=70

Ubuntu log:

av=18.1.0
ffmpeg=8.1.2
platform=linux
backend=v4l2
device='/dev/video0'
path='/dev/video0'
options={}
start threads=1
1 frames=30 threads=1
2 frames=30 threads=1
3 frames=30 threads=1
4 frames=30 threads=1
5 frames=30 threads=1
6 frames=30 threads=1
7 frames=30 threads=1
8 frames=30 threads=1
9 frames=30 threads=1
10 frames=30 threads=1
11 frames=30 threads=1
12 frames=30 threads=1
13 frames=30 threads=1
14 frames=30 threads=1
15 frames=30 threads=1
16 frames=30 threads=1
17 frames=30 threads=1
18 frames=30 threads=1
19 frames=30 threads=1
20 frames=30 threads=1

Reproduction script:

Run the script with: uv run --with av --with psutil python thread_retention.py

from __future__ import annotations

import argparse
import sys
import time
from pathlib import Path

import av
import psutil

WINDOWS_DEVICE = "Integrated Webcam"
LINUX_DEVICE = "/dev/video0"


def default_backend() -> str:
    return "dshow" if sys.platform == "win32" else "v4l2"


def default_device(backend: str) -> str:
    if backend == "dshow":
        return WINDOWS_DEVICE
    return LINUX_DEVICE


def nthreads() -> int:
    return psutil.Process().num_threads()


def open_path(backend: str, device: str) -> str:
    if backend == "dshow":
        return f"video={device}"
    return device


def open_options(args: argparse.Namespace) -> dict[str, str]:
    options: dict[str, str] = {}
    if args.backend == "dshow":
        options["rtbufsize"] = args.rtbufsize
    if args.width and args.height:
        options["video_size"] = f"{args.width}x{args.height}"
    if args.framerate:
        options["framerate"] = str(args.framerate)
    if args.input_format:
        options["input_format"] = args.input_format
    return options


def read_frames(container: av.container.InputContainer, count: int) -> int:
    stream = next(s for s in container.streams if s.type == "video")
    taken = 0
    for packet in container.demux(stream):
        if getattr(packet, "size", 0) <= 0:
            continue
        for _frame in packet.decode():
            taken += 1
            if taken >= count:
                return taken
    return taken


def list_linux_nodes() -> list[str]:
    return [str(path) for path in sorted(Path("/dev").glob("video*"))]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--backend",
        choices=("dshow", "v4l2"),
        default=default_backend(),
    )
    parser.add_argument(
        "--device",
        default=None,
        help="dshow friendly name, or v4l2 node (default: Integrated Webcam / /dev/video0)",
    )
    parser.add_argument("--cycles", type=int, default=20)
    parser.add_argument("--frames", type=int, default=30)
    parser.add_argument("--cooldown", type=float, default=2.0)
    parser.add_argument("--width", type=int, default=None)
    parser.add_argument("--height", type=int, default=None)
    parser.add_argument("--framerate", default=None)
    parser.add_argument("--input-format", dest="input_format", default=None)
    parser.add_argument("--rtbufsize", default="256M")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    args.device = args.device or default_device(args.backend)
    options = open_options(args)
    path = open_path(args.backend, args.device)

    print(f"av={av.__version__}")
    print(f"ffmpeg={av.ffmpeg_version_info}")
    print(f"platform={sys.platform}")
    print(f"backend={args.backend}")
    print(f"device={args.device!r}")
    print(f"path={path!r}")
    print(f"options={options}")
    print(f"start threads={nthreads()}")

    for i in range(args.cycles):
        container = av.open(path, format=args.backend, options=options)
        taken = read_frames(container, args.frames)
        container.close()
        time.sleep(args.cooldown)
        print(f"{i + 1} frames={taken} threads={nthreads()}")

if __name__ == "__main__":
    main()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions