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()
Thread retention after calling PyAV’s
container.close()is consistently reproducible.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:
Ubuntu log:
Reproduction script:
Run the script with:
uv run --with av --with psutil python thread_retention.py