【Python】同期非同期問わず関数の実行時間を計測するデコレータ

  • 2024年3月11日
  • 2024年3月11日
  • Python

 Pythonにはデコレータという機能があります。これは関数を受け取り、その関数を元にした関数を返す処理を書きやすくする機能です。これは例えば次のように使えます。

import functools
import time


# デコレータを用意。関数を受け取って関数を返せばOK
def log_function_time(func):
    """関数の実行にかかった時間と引数を表示するデコレーター"""

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()  # 開始時間
        result = func(*args, **kwargs)  # 関数の実行
        end_time = time.time()  # 終了時間
        # 関数が定義されているファイル名と関数名、引数、かかった時間を表示
        filename = func.__code__.co_filename
        print(f"{filename}:{func.__name__!r}({args}, {kwargs}) {end_time - start_time:.4f} sec")
        return result

    return wrapper


# @名前とすると関数をデコレータに渡せる
@log_function_time
def wait1sec():
    time.sleep(1)


if __name__ == '__main__':
    wait1sec()
    # log_function_timeを使っているため、以下のような出力がされます
    # C:\xxx\tmp.py:'wait1sec'((), {}) 1.0117 sec

 関数をラッピングする関数はどの言語でも書くことがままあります。デコレータはそういった部分を書きやすく、使いやすくしてくれます。

 上記のコードはまあまあ便利なのですが非同期関数に対応していません。もしそのまま使うと次のようになります。

import asyncio

@log_function_time
async def test_async(x, y):
    """非同期関数のテスト"""
    await asyncio.sleep(1)  # 非同期に時間がかかる処理を模倣
    return x * y

if __name__ == '__main__':
    asyncio.run(test_async(2,3))
    # C:\xxx\tmp.py:'test_async'((2, 3), {}) 0.0000 secs

 非同期の部分を待たずに計測完了としてしまうため0秒と表示されてしまいます。適切にawaitする必要があります。つまり次のようになります。

def log_async_function_time(func):
    """非同期関数の実行にかかった時間と引数を表示するデコレーター"""

    # 非同期処理に対応するために async def を使う
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        start_time = time.time() 
        result = await func(*args, **kwargs)  # 非同期関数の実行を待機
        end_time = time.time() 
        filename = func.__code__.co_filename
        print(f"{filename}:{func.__name__!r}({args}, {kwargs}) {end_time - start_time:.4f} sec")
        return result

    return wrapper

if __name__ == '__main__':
    asyncio.run(test_async(2,3))
    # C:\xxx\tmp.py:'test_async'((2, 3), {}) 1.0049 sec

 こちらを使うとしっかりと待機して実行時間が表示されます。

 このままでも十分使えるのですが、どちらのデコレータを用いるかの使い分けが出るのは手間です。そこで引数の関数が非同期関数か否かをチェックして適切に処理するようにします。この実装が次です。

import functools
import time
import asyncio


def log_function_time(func):
    """関数の実行にかかった時間と引数をプリントするデコレータ。同期及び非同期関数に対応。"""

    @functools.wraps(func)
    def sync_wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        filename = func.__code__.co_filename
        print(f"{filename}:{func.__name__!r}({args}, {kwargs}) took {end_time - start_time:.4f} sec")
        return result

    @functools.wraps(func)
    async def async_wrapper(*args, **kwargs):
        start_time = time.time()
        result = await func(*args, **kwargs)
        end_time = time.time()
        filename = func.__code__.co_filename
        print(f"{filename}:{func.__name__!r}({args}, {kwargs}) took {end_time - start_time:.4f} sec")
        return result

    # asyncio.iscoroutinefunction で非同期関数かどうかを判定
    if asyncio.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper


@log_function_time
def test_sync():
    """同期関数のテスト"""
    time.sleep(1)


@log_function_time
async def test_async():
    """非同期関数のテスト"""
    await asyncio.sleep(1)  # 非同期に時間がかかる処理を模倣


if __name__ == '__main__':
    test_sync()
    asyncio.run(test_async())
    # C:\xxx\tmp.py:'test_sync'((), {}) took 1.0061 sec
    # C:\xxx\tmp.py:'test_async'((), {}) took 1.0061 sec

 Python組み込み関数であるasyncio.iscoroutinefunctionを用いて関数が非同期関数であるか否かをチェックし、非同期ならば非同期用処理、同期ならば同期用処理に流し込みます。こうすることでデコレータを呼び出す側は非同期関数、同期関数とそれぞれに対応するデコレータを考えずに済み、楽ができます。

 先ほどのコードは使う分には便利なのですが、全く同じ意味で同じことするコードが二か所にあることとなりメンテナンスがちょっとめんどくさいです(1関数の内部なのでマシな方ではありますが)。そこで非同期、動機が絡まない部分は完全に共通化して改修しやすくしたくなります。これを実装したのが次です。

def log_function_time(func):
    """関数の実行にかかった時間と引数をプリントするデコレータ。同期及び非同期関数に対応。"""

    # 関数の実行前の処理
    def pre_call():
        start_time = time.time()
        return start_time

    # 関数の実行後の処理
    def post_call(start_time, *args, **kwargs):
        end_time = time.time()
        filename = func.__code__.co_filename
        print(f"{filename}:{func.__name__!r}({args}, {kwargs}) took {end_time - start_time:.4f} sec")

    @functools.wraps(func)
    def sync_wrapper(*args, **kwargs):
        start_time = pre_call()
        result = func(*args, **kwargs)
        post_call(start_time, *args, **kwargs)
        return result

    @functools.wraps(func)
    async def async_wrapper(*args, **kwargs):
        start_time = pre_call()
        result = await func(*args, **kwargs)
        post_call(start_time, *args, **kwargs)
        return result

    # asyncio.iscoroutinefunction で非同期関数かどうかを判定
    if asyncio.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper

 前後の処理をそれぞれ関数にしました。単純な方法ですがコードを改修したい時に変更する部分は大体この処理の前後の部分だけなので結構効果的です。こんな感じで同期処理非同期処理を問わず関数の実行時間を計測できます。またpre_callやpost_callの中身を書き換えることで他の何かをしたいデコレータにも同期、非同期を問わない書き方を応用できます。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG