Faster Python: Python グローバルインタープリターロックの解消
Python のグローバルインタープリターロック(GIL)とは?
「グローバルインタープリターロック」(別名「GIL」)は Python コミュニティでは一般的な用語であり、 よく知られている Python の機能です。 では、GIL とは実際には何なのでしょうか?
他のプログラミング言語(Rust など)をご経験の方は、ミューテックスという用語はすでにご存知かもしれません。 「相互排他制御(Mutual Exclusion)」の略です。 ミューテックスは一度に 1 つのスレッドのみがデータにアクセスすることを許可し、 同時に複数のスレッドがデータを変更することを防ぐ仕組みです。 これは、キーを持つ 1 つのスレッドを除き、すべてのスレッドのデータへのアクセスをブロックするという「ロック」のように考えるとよいでしょう。
GIL は技術的にはミューテックスそのものです。 一度に 1 つのスレッドのみが Python インタープリターにアクセスすることを許可します。 私はこの仕組みを Python における車のハンドルだと考えることがあります。 ハンドル操作は複数の人には任せられませんよね! とは言え、グループで車旅をするときは運転を交代することもよくあるでしょう。 これは、インタープリターへのアクセスを別のスレッドに引き渡すのと同じことです。
Python はこの GIL が原因で真のマルチスレッドプロセスを実現できません。 この機能は過去 10 年間にわたって議論を巻き起こしており、GIL を排除してマルチスレッドプロセスを可能にすることにより、Python を高速化しようとする多くの試みが行われてきました。 最近の Python 3.13 では、GIL を使わずに Python を使用するオプション(別名: no-GIL または free-threaded Python)が公開されました。 つまり、Python プログラミングの新しい時代の幕が開いたのです。
GIL が導入されたそもそもの理由とは?
GIL の人気がそれほど低いのなら、それが導入されたそもそもの理由は何なのでしょうか? GIL には実際にメリットがあります。 真のマルチスレッド処理を実現している他のプログラミング言語では、複数のスレッドがデータ変更を試みた際に先に終了するスレッドやプロセスによって最終的な結果が変わり、問題が発生する場合があります。 このような競合状態は「レースコンディション」と呼ばれています。 Rust のような言語が往々にして習得困難なのは、プログラマーがミューテックスを使用してレースコンディションを防ぐ必要があるためです。
Python ではすべてのオブジェクトが参照カウンターを保持しており、個々のオブジェクトの情報を必要としている他のオブジェクトの数が追跡されます。 Python には GIL があるためレースコンディションが存在しないことが分かっているため、参照カウンターがゼロに達しているオブジェクトは不要であり、ガベージコレクションの対象にできることを自信をもって宣言することができます。
Python が 1991 年に初めてリリースされた頃、ほとんどのパソコンには 1 つのコアしかなかったため、マルチスレッド処理のサポートをリクエストするプログラマーもあまりいませんでした。 GIL があればプログラムの実装上の多くの問題が解決され、コードも保守しやすくなります。 そのような理由により、GIL は 1992 年に Guido van Rossum(Python の開発者)によって追加されました。
2025 年まで時を進めましょう。今ではパソコンにマルチコアプロセッサーが搭載されており、計算能力も大幅に向上しています。 この有り余る能力を活用すれば、GIL を排除することなく真の並行性を実現できます。
この記事の後半では GIL を排除するプロセスを詳しく説明しますが、 まずは GIL がある前提で真の並行性がどのように機能するかを見てみましょう。
Python でのマルチプロセス処理
GIL の排除プロセスを詳しく見る前に、Python 開発者が multiprocessing ライブラリを使用して真の並行性をどのように実現しているのかを確認しましょう。 multiprocessing 標準ライブラリはローカルとリモートの両方の並行性を提供し、サブプロセスをスレッドの代わりに使用することで、グローバルインタープリターロックを効果的に回避できるようにします。 このようにすることで、multiprocessing モジュールはプログラマーが特定マシンの複数のプロセッサーをフル活用できるようにします。
ただし、マルチプロセス処理を実行するにはプログラムの設計を少しだけ変える必要があります。 Python で multiprocessing ライブラリを使用する以下の例を見てみましょう。
このブログ連載記事のパート 1 で見た非同期のバーガーショップを思い出してください。
import asyncio
import time
async def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
async def main():
order_queue = []
for i in range(3):
order_queue.append(make_burger(i))
await asyncio.gather(*(order_queue))
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
これと同じ処理を以下のように multiprocessing ライブラリを使用して実現できます。
import multiprocessing
import time
def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
if __name__ == "__main__":
print("Number of available CPU:", multiprocessing.cpu_count())
s = time.perf_counter()
all_processes = []
for i in range(3):
process = multiprocessing.Process(target=make_burger, args=(i,))
process.start()
all_processes.append(process)
for process in all_processes:
process.join()
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
ご存知かもしれませんが、multiprocessing の多くのメソッドは threading に非常によく似ています。 multiprocessing での違いを確認するため、より複雑なユースケースを見てみましょう。
import multiprocessing
import time
import queue
def make_burger(order_num, item_made):
name = multiprocessing.current_process().name
print(f"{name} is preparing burger #{order_num}...")
time.sleep(5) # time for making burger
item_made.put(f"Burger #{order_num}")
print(f"Burger #{order_num} made by {name}")
def make_fries(order_num, item_made):
name = multiprocessing.current_process().name
print(f"{name} is preparing fries #{order_num}...")
time.sleep(2) # time for making fries
item_made.put(f"Fries #{order_num}")
print(f"Fries #{order_num} made by {name}")
def working(task_queue, item_made, order_num, lock):
break_count = 0
name = multiprocessing.current_process().name
while True:
try:
task = task_queue.get_nowait()
except queue.Empty:
print(f"{name} has nothing to do...")
if break_count > 1:
break # stop if idle for too long
else:
break_count += 1
time.sleep(1)
else:
lock.acquire()
try:
current_num = order_num.value
order_num.value = current_num + 1
finally:
lock.release()
task(current_num, item_made)
break_count = 0
if __name__ == "__main__":
print("Welcome to Pyburger! Please place your order.")
burger_num = input("Number of burgers:")
fries_num = input("Number of fries:")
s = time.perf_counter()
task_queue = multiprocessing.Queue()
item_made = multiprocessing.Queue()
order_num = multiprocessing.Value("i", 0)
lock = multiprocessing.Lock()
for i in range(int(burger_num)):
task_queue.put(make_burger)
for i in range(int(fries_num)):
task_queue.put(make_fries)
staff1 = multiprocessing.Process(
target=working,
name="John",
args=(
task_queue,
item_made,
order_num,
lock,
),
)
staff2 = multiprocessing.Process(
target=working,
name="Jane",
args=(
task_queue,
item_made,
order_num,
lock,
),
)
staff1.start()
staff2.start()
staff1.join()
staff2.join()
print("All tasks finished. Closing now.")
print("Items created are:")
while not item_made.empty():
print(item_made.get())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
次のような出力が得られます。
Welcome to Pyburger! Please place your order. Number of burgers:3 Number of fries:2 Jane has nothing to do... John is preparing burger #0... Jane is preparing burger #1... Burger #0 made by John John is preparing burger #2... Burger #1 made by Jane Jane is preparing fries #3... Fries #3 made by Jane Jane is preparing fries #4... Burger #2 made by John John has nothing to do... Fries #4 made by Jane Jane has nothing to do... John has nothing to do... Jane has nothing to do... John has nothing to do... Jane has nothing to do... All tasks finished. Closing now. Items created are: Burger #0 Burger #1 Fries #3 Burger #2 Fries #4 Orders completed in 12.21 seconds.
multiprocessing にはいくつかの制限があり、上記のコードはそれを理由にこのように設計されていることに注意してください。 それでは、1 つずつ説明していきましょう。
まず、正しい order_num で関数を生成する make_burger 関数と make_fries 関数があったことを思い出してください。
def make_burger(order_num):
def making_burger():
logger.info(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making burger
logger.info(f"Burger made #{order_num}")
return making_burger
def make_fries(order_num):
def making_fries():
logger.info(f"Preparing fries #{order_num}...")
time.sleep(2) # time for making fries
logger.info(f"Fries made #{order_num}")
return making_fries
multiprocessing を使用する限り、同じことは行えません。 そうしようとすると、以下のようなエラーが発生します。
AttributeError: Can't get local object 'make_burger..making_burger'
これは、multiprocessing が概してトップモジュールレベルの関数しかシリアル化できない pickle を使用していることが原因です。 multiprocessing の制限の 1 つです。
次に、multiprocessing を使用する上記のサンプルコード片では共有対象のデータにグローバル変数を使用していないことに注意してください。 たとえば、item_made と order_num にグローバル変数を使用することはできません。 異なるプロセス間でデータを共有するには、multiprocessing ライブラリの Queue や Value などの特別なクラスオブジェクトが使用され、引数としてプロセスに渡されます。
プロセス間でのデータと状態の共有は、一般的には推奨されていません。それにより、多くの問題が発生する可能性があるためです。 上記の例では、Lock を使用して order_num の値が一度に 1 つのプロセスによってのみアクセスされ、増分されるようにしています。 Lock を使用しない場合、商品の注文番号は以下のように乱雑になる可能性があります。
Items created are: Burger #0 Burger #0 Fries #2 Burger #1 Fries #3
以下のように lock を使用すると、この問題を回避することができます。
lock.acquire()
try:
current_num = order_num.value
order_num.value = current_num + 1
finally:
lock.release()
task(current_num, item_made)
multiprocessing 標準ライブラリの使用方法についての詳細は、こちらのドキュメントをご覧ください。
GIL の排除
GIL の排除は約 10 年間にわたって議論されてきました。 2016 年の Python Language Summit では、Larry Hastings が CPython インタープリターで「GIL の廃止」を実行する考えとその構想の進捗を発表しました[1]。 これは Python の GIL を排除する先駆的な試みでした。 2021 年 には Sam Gross が GIL の排除に関する議論に再び火をつけ[2]、これが 2023 年の PEP 703 – Making the Global Interpreter Lock Optional in CPython(CPython でグローバルインタープリターロックをオプションにする)のきっかけになりました。
このように、GIL の排除は決して急に決まったものではなく、コミュニティ内では大きな議論の的となってきたものです。 上記のマルチプロセス処理の例(および上記でリンクされている PEP 703)で示されているように、GIL による保証がなくなると、事態は急速に複雑化します。
[1]: https://lwn.net/Articles/689548/
[2]: https://lwn.net/ml/python-dev/CAGr09bSrMNyVNLTvFq-h6t38kTxqTXfgxJYApmbEWnT71L74-g@mail.gmail.com/
参照カウント
GIL がある場合、参照カウントとガベージコレクションはもっと単純明快です。 一度に 1 つのスレッドのみが Python オブジェクトにアクセスできる場合は単純な非アトミック参照カウントを使用し、参照カウントがゼロに達したときにオブジェクトを除去することができます。
GIL を排除すると、事態は複雑化します。 スレッドの安全性を保証しない非アトミック参照カウントを使用できなくなるのです。 同時に複数のスレッドが Python オブジェクトに対して複数の参照の増分と減分を実行している場合、問題が発生する可能性があります。 アトミック参照カウントによってスレッドの安全性が保証されるのが理想的ですが、 この方法はオーバーヘッドが大きく、スレッド数が多い場合に効率が低下してしまいます。
この問題は、スレッドの安全性も保証するバイアス参照カウントを使用すると解決できます。 アイデアとしては、各オブジェクトをオーナースレッド(ほとんどの場合に特定のオブジェクトにアクセスしているスレッド)に偏らせるというものです。 オーナースレッドは自身が所有するオブジェクトに対して非アトミック参照カウントを実行できますが、他のスレッドはそのようなオブジェクトにアトミック参照カウントを実行する必要があります。 この方法が普通のアトミック参照カウントよりも望ましいのは、ほとんどのオブジェクトはほとんどの場合に 1 つのスレッドからのみアクセスされるためです。 オーナースレッドが非アトミック参照カウントを実行できるようにすることで、実行のオーバーヘッドを削減することができます。

また、よく使用される一部の Python オブジェクト(True、False、小さな整数、一部の intern された文字列など)は永続化されます。 ここで、「永続」とはオブジェクトがその生存時間中にプログラムに留まるという意味であるため、参照カウントは必要としません。
ガベージコレクション
ガベージコレクションの実行方法も変更する必要があります。 参照が解放されたらすぐに参照カウントを減らし、参照カウントがゼロに到達したら直ちにオブジェクトを除去する代わりに、「遅延参照カウント」という手法が使用されます。
参照カウントを減らす必要がある場合、オブジェクトはテーブルに格納され、この参照カウントの減少が正確であるかどうかが再確認されます。 こうすることで、GIL がない場合にまだ参照されているオブジェクトが途中で除去される状況を回避しています。参照カウントが GIL がある場合ほど単純明快ではないためです。 これにより、ガベージコレクションのプロセスが複雑化します。ガベージコレクションは各スレッドのスタックごとに独自の参照カウントを全探索する必要になる場合があるためです。
もう 1 つ考慮すべきなのは、ガベージコレクション中は参照カウントが安定している必要があるということです。 破棄されようとしているオブジェクトが不意に参照された場合、深刻な問題が発生します。 そのため、ガベージコレクションのサイクル中にカウントを停止し、スレッドの安全性を保証する必要があります。
メモリの割り当て
GIL がスレッド安全性を保証しているときは Python の内部メモリアロケーターである pymalloc が使用されます。 しかし、GIL がない場合は新しいメモリアロケーターが必要となります。 Sam Gross は Daan Leijen が開発し、Microsoft が保守する汎用アロケーターである mimalloc を PEP で提案しました。 これはスレッドセーフであり、小さなオブジェクトで良好なパフォーマンスを発揮するため、有効な選択肢です。
Mimalloc はそのヒープをページで埋め、ページをブロックで埋めます。 各ページにはブロックが含まれ、各ページ内のブロックはすべて同じサイズです。 リストと辞書のアクセスに制限を加えることで、ガベージコレクターが連結リストを維持してすべてのオブジェクトを検索する必要がなくなり、ロックを取得することなくこのリストと辞書を読み取れるようになります。

GIL の排除に関する詳細な情報は存在しますが、そのすべてをこの記事で説明するのは不可能です。 完全な内容については、PEP 703 – Making the Global Interpreter Lock Optional in CPython(CPython でグローバルインタープリターロックをオプションにする)をご覧ください。
GIL の有無によるパフォーマンスの違い
Python 3.13 には free-threaded オプションがあるため、Python 3.13 の標準バージョンのパフォーマンスを free-threaded バージョンと比較することができます。
free-threaded Python のインストール
pyenv を使用し、標準バージョン(3.13.5 など)と free-threaded バージョン(3.13.5t など)の両方のバージョンをインストールします。
または、Python.org のインストーラーを使用することもできます。 インストール中は必ず Customize オプションを選択し、free-threaded Python をインストールするオプションのボックスをオンにしてください(こちらのブログ記事の例をご覧ください)。
両方のバージョンがインストールされたら、それらを PyCharm プロジェクト内でインタープリターとして追加できます。
まず、右下の Python インタープリターの名前をクリックします。

メニューから Add New Interpreter(新規インタープリターの追加)と Add Local Interpreter(ローカルインタープリターの追加)を続けて選択します。

Select existing(既存の選択)を選択し、インタープリターパスが読み込まれるのを待ちます(インタープリターが多い場合は読み込みに時間がかかる場合があります)。その後、Python path (Python のパス)ドロップダウンメニューからインストールしたばかりの新しいインタープリターを選択します。

OK をクリックして追加します。 もう 1 つのインタープリターでも同じ手順を繰り返します。 右下のインタープリター名をもう一度クリックすると、上記の画像のように複数の Python 3.13 インタープリターが表示されます。
CPU バウンドなプロセスを使用したテスト
次に、複数の異なるバージョンをテストするためのスクリプトが必要です。 このブログ連載記事のパート 1 では、CPU バウンドなプロセスを高速化するには真のマルチスレッド処理が必要だと説明しました。 GIL を排除することで真のマルチスレッド処理が可能になり、Python が高速化されるかどうかを確認するには、複数のスレッドで CPU バウンドなプロセスを使用してテストすることができます。 以下は、Junie に生成してもらったスクリプトです(最終調整は私が行いました)。
import time
import multiprocessing # Kept for CPU count
from concurrent.futures import ThreadPoolExecutor
import sys
def is_prime(n):
"""Check if a number is prime (CPU-intensive operation)."""
if n <= 1:
return False
if n <= 3:
return True
if n % 2 == 0 or n % 3 == 0:
return False
i = 5
while i * i <= n:
if n % i == 0 or n % (i + 2) == 0:
return False
i += 6
return True
def count_primes(start, end):
"""Count prime numbers in a range."""
count = 0
for num in range(start, end):
if is_prime(num):
count += 1
return count
def run_single_thread(range_size, num_chunks):
"""Run the prime counting task in a single thread."""
chunk_size = range_size // num_chunks
total_count = 0
start_time = time.time()
for i in range(num_chunks):
start = i * chunk_size + 1
end = (i + 1) * chunk_size + 1 if i < num_chunks - 1 else range_size + 1
total_count += count_primes(start, end)
end_time = time.time()
return total_count, end_time - start_time
def thread_task(start, end):
"""Task function for threads."""
return count_primes(start, end)
def run_multi_thread(range_size, num_threads, num_chunks):
"""Run the prime counting task using multiple threads."""
chunk_size = range_size // num_chunks
total_count = 0
start_time = time.time()
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = []
for i in range(num_chunks):
start = i * chunk_size + 1
end = (i + 1) * chunk_size + 1 if i < num_chunks - 1 else range_size + 1
futures.append(executor.submit(thread_task, start, end))
for future in futures:
total_count += future.result()
end_time = time.time()
return total_count, end_time - start_time
def main():
# Fixed parameters
range_size = 1000000 # Range of numbers to check for primes
num_chunks = 16 # Number of chunks to divide the work into
num_threads = 4 # Fixed number of threads for multi-threading test
print(f"Python version: {sys.version}")
print(f"CPU count: {multiprocessing.cpu_count()}")
print(f"Range size: {range_size}")
print(f"Number of chunks: {num_chunks}")
print("-" * 60)
# Run single-threaded version as baseline
print("Running single-threaded version (baseline)...")
count, single_time = run_single_thread(range_size, num_chunks)
print(f"Found {count} primes in {single_time:.4f} seconds")
print("-" * 60)
# Run multi-threaded version with fixed number of threads
print(f"Running multi-threaded version with {num_threads} threads...")
count, thread_time = run_multi_thread(range_size, num_threads, num_chunks)
speedup = single_time / thread_time
print(f"Found {count} primes in {thread_time:.4f} seconds (speedup: {speedup:.2f}x)")
print("-" * 60)
# Summary
print("SUMMARY:")
print(f"{'Threads':<10} {'Time (s)':<12} {'Speedup':<10}")
print(f"{'1 (baseline)':<10} {single_time:<12.4f} {'1.00x':<10}")
print(f"{num_threads:<10} {thread_time:<12.4f} {speedup:.2f}x")
if __name__ == "__main__":
main()
複数の異なる Python インタープリターでスクリプトを実行しやすくするため、Python プロジェクトにカスタム実行スクリプトを追加することができます。
最上部の Run(実行、
)ボタンの横にあるドロップダウンから Edit Configurations…(構成の編集…)を選択します。

左上の + ボタンをクリックし、Add New Configuration(新規構成の追加)ドロップダウンメニューから Python を選択します。

具体的にどちらのインタープリター(3.13.5、3.15.3t など)が使用されているのか分かるような名前を決めます。 以下のように、適切なインタープリターを選択してテストスクリプトの名前を追加します。

インタープリターごとに 1 つずつ、合計で 2 つの構成を追加します。 そして OK をクリックします。
構成を選択して上部の Run(実行、
)ボタンをクリックすることで、GIL の有無を問わずテストスクリプトを簡単に選択して実行できるようになりました。
結果の比較
GIL のある標準バージョンの 3.13.5 を実行すると、以下の結果が得られました。
Python version: 3.13.5 (main, Jul 10 2025, 20:33:15) [Clang 17.0.0 (clang-1700.0.13.5)] CPU count: 8 Range size: 1000000 Number of chunks: 16 ------------------------------------------------------------ Running single-threaded version (baseline)... Found 78498 primes in 1.1930 seconds ------------------------------------------------------------ Running multi-threaded version with 4 threads... Found 78498 primes in 1.2183 seconds (speedup: 0.98x) ------------------------------------------------------------ SUMMARY: Threads Time (s) Speedup 1 (baseline) 1.1930 1.00x 4 1.2183 0.98x
ご覧のように、このバージョンを 4 スレッドで実行した場合はシングルスレッドの基準から大きな変化はありません。 free-threaded バージョンの 3.13.5t を実行した場合を見てみましょう。
Python version: 3.13.5 experimental free-threading build (main, Jul 10 2025, 20:36:28) [Clang 17.0.0 (clang-1700.0.13.5)] CPU count: 8 Range size: 1000000 Number of chunks: 16 ------------------------------------------------------------ Running single-threaded version (baseline)... Found 78498 primes in 1.5869 seconds ------------------------------------------------------------ Running multi-threaded version with 4 threads... Found 78498 primes in 0.4662 seconds (speedup: 3.40x) ------------------------------------------------------------ SUMMARY: Threads Time (s) Speedup 1 (baseline) 1.5869 1.00x 4 0.4662 3.40x
今度は 3 倍以上の速度になりました。 どちらの場合もマルチスレッド処理のオーバーヘッドがあることに注意してください。 そのため、真のマルチスレッド処理であっても、4 スレッドでの速度は 4 倍になりません。
まとめ
Faster Python ブログ連載記事のパート 2 では、過去に Python GIL が導入された背景、multiprocessing を使用した GIL の制限の回避、およびGIL の排除プロセスとその効果について説明しました。
このブログ記事を投稿した時点では、Python の free-threaded バージョンはまだデフォルトになっていません。 ただし、コミュニティとサードパーティのライブラリの採用により、コミュニティでは Python の free-threaded バージョンが将来的に標準となることが期待されています。 Python 3.14 には実験的段階を終えた free-threaded バージョンが含まれると発表されてはいますが、現時点ではオプションです。
PyCharm は最高水準の Python のサポートを提供し、速度と精度の両方を確保しています。 最もスマートなコード補完、PEP 8 準拠チェック、インテリジェントなリファクタリング、および各種のインスペクションを活用し、コーディングのあらゆるニーズに対応することができます。 このブログ記事で紹介したように、PyCharm は Python インタープリターと実行構成の設定をカスタマイズ可能なため、数回クリックするだけでインタープリターを切り替え、幅広い Python プロジェクトに最適な環境にすることができます。
オリジナル(英語)ブログ投稿記事の作者:
Subscribe to PyCharm Blog updates
Discover more
더 빨라진 Python: Python의 전역 인터프리터 잠금 해제
Python의 전역 인터프리터 잠금(GIL)이란?
‘전역 인터프리터 잠금'(또는 GIL)은 Python 커뮤니티에서는 익숙한 용어로, 잘 알려진 Python 작동 방식입니다. 그렇다면 GIL은 정확히 무엇일까요?
다른 프로그래밍 언어(예: Rust)를 사용해 본 적이 있다면 뮤텍스가 무엇인지 아실 겁니다. 뮤텍스는 ‘mutual exclusion’의 줄임말입니다. 뮤텍스는 한 번에 하나의 스레드만 데이터에 액세스할 수 있도록 합니다. 그러면 여러 스레드가 동시에 데이터를 수정하는 것을 방지할 수 있습니다. 이는 키를 보유한 하나의 스레드를 제외한 모든 스레드가 데이터에 접근하지 못하도록 하는 일종의 ‘자물쇠’ 역할을 합니다.
GIL은 형식상 뮤텍스입니다. 한 번에 하나의 스레드만 Python 인터프리터에 액세스할 수 있습니다. 때로는 GIL이 Python의 운전대처럼 느껴지기도 합니다. 운전대를 두 명 이상이 잡는 것은 바람직하지 않으니까요! 하지만 단체 여행을 갈 때는 운전자를 교대하는 경우가 많이 있죠. 이는 다른 스레드에 인터프리터 액세스 권한을 부여하는 것과 비슷합니다.
하지만 GIL 때문에 Python에서는 진정한 멀티스레딩 프로세스가 허용되지 않습니다. 이 기능은 지난 10년 동안 논쟁을 불러일으켰으며, GIL을 제거하고 멀티스레딩 프로세스를 허용하여 Python 속도를 높이려는 시도가 많이 있었습니다. 최근 Python 3.13에서는 GIL없이 Python을 사용할 수 있는 옵션이 도입되었습니다. 이를 종종 no-GIL Python 또는 free-threaded Python(자유로운 스레드 처리 지원 Python)이라고 부릅니다. 이로써 Python 프로그래밍의 새로운 시대가 시작된 것입니다.
처음에 GIL이 구현된 이유
GIL이 그렇게 인기가 없다면 애초에 왜 이를 구현한 것일까요? 실제로 GIL에는 많은 이점이 있습니다. 진정한 멀티스레딩을 갖춘 다른 프로그래밍 언어에서는 여러 스레드가 데이터를 수정할 때 문제가 발생하는 경우가 있으며, 최종 결과가 어느 스레드나 프로세스가 먼저 끝나는지에 따라 달라질 수 있습니다. 이것을 ‘경쟁 상태’라고 합니다. Rust와 같은 언어는 프로그래머가 경쟁 상태를 방지하기 위해 뮤텍스를 사용해야 하기 때문에 배우기 어려운 경우가 많습니다.
Python에서 모든 객체는 자신에게 정보를 요청하는 객체의 수를 추적하는 참조 카운터를 가지고 있습니다. 참조 카운터가 0에 도달하면 GIL로 인해 Python에 경쟁 상태가 없다는 뜻이므로 해당 객체가 더 이상 필요하지 않으며 가비지 컬렉션이 가능하다고 확실히 선언할 수 있습니다.
1991년 Python이 처음 출시되었을 당시 대부분의 개인용 컴퓨터에는 코어가 하나뿐이었고, 멀티스레딩 지원을 요청한 프로그래머는 많지 않았습니다. GIL을 사용하면 프로그램 구현 시 많은 문제를 해결할 수 있고, 코드 유지 관리도 더 쉬워집니다. 이러한 이유로 Python을 만든 Guido van Rossum은 1992년에 GIL을 추가했습니다.
2025년이 된 지금, 개인용 컴퓨터에는 멀티코어 프로세서가 탑재되어 예전보다 훨씬 강력한 컴퓨팅 성능이 크게 향상되었습니다. 이 추가적인 컴퓨팅 성능을 활용해 GIL을 없애지 않고도 진정한 동시성을 얻을 수 있게 되었습니다.
이를 제거하는 과정은 이 글의 뒷부분에서 자세히 설명하겠습니다. 지금은 GIL이 있는 상태에서 진정한 동시성이 어떻게 작동하는지 살펴보겠습니다.
Python에서의 멀티프로세싱
GIL 제거 과정을 자세히 살펴보기 전에 먼저 Python 개발자가 멀티프로세싱 라이브러리를 사용하여 진정한 동시성을 달성할 수 있는 방법을 알아보겠습니다. 멀티프로세싱 표준 라이브러리는 로컬 및 원격 동시성을 모두 제공하며, 스레드 대신 하위 프로세스를 사용하여 전역 인터프리터 잠금을 효과적으로 우회합니다. 이런 식으로 멀티프로세싱 모듈을 사용하면 프로그래머가 특정 컴퓨터의 여러 프로세서를 완전하게 활용할 수 있습니다.
하지만 멀티프로세싱을 수행하려면 프로그램을 약간 다른 방식으로 설계해야 합니다. 다음 예시에서는 Python에서 멀티프로세싱 라이브러리를 사용하는 방법을 보여줍니다.
블로그 시리즈 1부에서 소개한 비동기 햄버거집을 기억하시나요?
import asyncio
import time
async def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
async def main():
order_queue = []
for i in range(3):
order_queue.append(make_burger(i))
await asyncio.gather(*(order_queue))
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
멀티프로세싱 라이브러리를 사용하여 동일한 작업을 수행할 수 있습니다. 예를 들면 다음과 같습니다.
import multiprocessing
import time
def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
if __name__ == "__main__":
print("Number of available CPU:", multiprocessing.cpu_count())
s = time.perf_counter()
all_processes = []
for i in range(3):
process = multiprocessing.Process(target=make_burger, args=(i,))
process.start()
all_processes.append(process)
for process in all_processes:
process.join()
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
멀티프로세싱의 많은 메서드가 스레딩과 매우 유사하다는 것을 기억하실 겁니다. 멀티프로세싱의 차이점을 이해하기 위해 좀 더 복잡한 사용 사례를 살펴보겠습니다.
import multiprocessing
import time
import queue
def make_burger(order_num, item_made):
name = multiprocessing.current_process().name
print(f"{name} is preparing burger #{order_num}...")
time.sleep(5) # time for making burger
item_made.put(f"Burger #{order_num}")
print(f"Burger #{order_num} made by {name}")
def make_fries(order_num, item_made):
name = multiprocessing.current_process().name
print(f"{name} is preparing fries #{order_num}...")
time.sleep(2) # time for making fries
item_made.put(f"Fries #{order_num}")
print(f"Fries #{order_num} made by {name}")
def working(task_queue, item_made, order_num, lock):
break_count = 0
name = multiprocessing.current_process().name
while True:
try:
task = task_queue.get_nowait()
except queue.Empty:
print(f"{name} has nothing to do...")
if break_count > 1:
break # stop if idle for too long
else:
break_count += 1
time.sleep(1)
else:
lock.acquire()
try:
current_num = order_num.value
order_num.value = current_num + 1
finally:
lock.release()
task(current_num, item_made)
break_count = 0
if __name__ == "__main__":
print("Welcome to Pyburger! Please place your order.")
burger_num = input("Number of burgers:")
fries_num = input("Number of fries:")
s = time.perf_counter()
task_queue = multiprocessing.Queue()
item_made = multiprocessing.Queue()
order_num = multiprocessing.Value("i", 0)
lock = multiprocessing.Lock()
for i in range(int(burger_num)):
task_queue.put(make_burger)
for i in range(int(fries_num)):
task_queue.put(make_fries)
staff1 = multiprocessing.Process(
target=working,
name="John",
args=(
task_queue,
item_made,
order_num,
lock,
),
)
staff2 = multiprocessing.Process(
target=working,
name="Jane",
args=(
task_queue,
item_made,
order_num,
lock,
),
)
staff1.start()
staff2.start()
staff1.join()
staff2.join()
print("All tasks finished. Closing now.")
print("Items created are:")
while not item_made.empty():
print(item_made.get())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
나오는 출력은 다음과 같습니다.
Welcome to Pyburger! Please place your order. Number of burgers:3 Number of fries:2 Jane has nothing to do... John is preparing burger #0... Jane is preparing burger #1... Burger #0 made by John John is preparing burger #2... Burger #1 made by Jane Jane is preparing fries #3... Fries #3 made by Jane Jane is preparing fries #4... Burger #2 made by John John has nothing to do... Fries #4 made by Jane Jane has nothing to do... John has nothing to do... Jane has nothing to do... John has nothing to do... Jane has nothing to do... All tasks finished. Closing now. Items created are: Burger #0 Burger #1 Fries #3 Burger #2 Fries #4 Orders completed in 12.21 seconds.
위 코드가 이런 방식으로 설계된 이유는 멀티프로세싱의 몇 가지 제한 때문입니다. 하나씩 살펴보겠습니다.
먼저, 앞서 올바른 order_num을 갖는 함수를 생성하기 위해 make_burger 및 make_fries 함수가 있었다는 점을 기억하세요.
def make_burger(order_num):
def making_burger():
logger.info(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making burger
logger.info(f"Burger made #{order_num}")
return making_burger
def make_fries(order_num):
def making_fries():
logger.info(f"Preparing fries #{order_num}...")
time.sleep(2) # time for making fries
logger.info(f"Fries made #{order_num}")
return making_fries
멀티프로세싱을 사용하면 동일한 작업을 수행할 수 없습니다. 그렇게 시도하면 다음 줄에서 오류가 발생합니다.
AttributeError: Can't get local object 'make_burger..making_burger'
그 이유는 멀티프로세싱이 일반적으로 최상위 모듈 수준 함수만 직렬화할 수 있는 pickle을 사용하기 때문입니다. 이는 멀티프로세싱의 한계 중 하나입니다.
두 번째로, 위의 멀티프로세싱을 사용한 예제 코드 스니펫에서 공유 데이터에 대해 전역 변수를 사용하지 않았다는 점에 유의하세요. 예를 들어, item_made와 order_num에 전역 변수를 사용할 수 없습니다. 서로 다른 프로세스 간에 데이터를 공유하기 위해 멀티프로세싱 라이브러리의 특수 클래스 객체(예: Queue 및 Value)가 사용되고 프로세스에 인수로 전달됩니다.
일반적으로, 서로 다른 프로세스 간에 데이터와 상태를 공유하는 것은 더 많은 문제를 야기할 수 있으므로 권장되지 않습니다. 위의 예에서는 잠금을 사용하여 order_num의 값이 한 번에 하나의 프로세스에 의해서만 접근되고 증가하도록 해야 합니다. 잠금이 없으면 품목의 주문 번호가 다음과 같이 엉망이 될 수 있습니다.
Items created are: Burger #0 Burger #0 Fries #2 Burger #1 Fries #3
문제를 피하기 위해 잠금을 사용하는 방법은 다음과 같습니다.
lock.acquire()
try:
current_num = order_num.value
order_num.value = current_num + 1
finally:
lock.release()
task(current_num, item_made)
멀멀티프로세싱 표준 라이브러리 사용법을 더 알아보고 싶으시다면, 이 문서를 참고하세요.
GIL 제거
GIL 제거는 거의 10년 동안 논의된 주제였습니다. 2016년 Python Language Summit에서 Larry Hastings는 CPython 인터프리터의 ‘GIL 제거’에 대한 자신의 아이디어와 이 아이디어로 이룬 성과를 발표했습니다[1]. 이는 Python GIL을 제거하려는 획기적인 시도였습니다. 2021년에 Sam Gross는 GIL[2] 제거에 대한 논의를 다시 시작했고, 이로 인해 2023년에 CPython에서 전역 인터프리터 잠금을 선택 사항으로 만드는 PEP 703이 출시되었습니다.
보시다시피, GIL을 제거하는 것은 결코 성급한 결정이 아니었으며 이미 커뮤니티 내에서 상당한 논쟁의 대상이 되어 왔습니다. 위의 멀티프로세싱 예제(및 위에 링크된 PEP 703)에서 볼 수 있듯이 GIL이 제공하던 보장이 사라지면 상황은 빠르게 복잡해집니다.
[1]: https://lwn.net/Articles/689548/
[2]: https://lwn.net/ml/python-dev/CAGr09bSrMNyVNLTvFq-h6t38kTxqTXfgxJYApmbEWnT71L74-g@mail.gmail.com/
참조 계산
GIL이 있으면 참조 계산과 가비지 컬렉션이 더 간단해집니다. 한 번에 하나의 스레드만 Python 객체에 접근할 수 있는 경우 간단한 비원자적 참조 계산을 사용하고 참조 계산이 0에 도달하면 객체를 제거할 수 있습니다.
GIL을 제거하면 상황이 조금 까다로워집니다. 스레드 안전이 보장되지 않으므로 비원자적 참조 계산을 더 이상 사용할 수 없습니다. 여러 스레드가 Python 객체에 대한 참조의 증가와 감소를 동시에 수행하는 경우 문제가 발생할 수 있습니다. 이상적으로는 원자적 참조 계산을 사용하여 스레드 안전성을 보장할 수 있습니다. 하지만 이 방법은 오버헤드가 높고 스레드가 많을 경우 효율성에 영향을 미칩니다.
해결책은 편향된 참조 계산을 사용하는 것인데, 이를 통해 스레드 안전성도 보장됩니다. 여기서 아이디어는 각 객체를 소유자 스레드, 즉 대부분의 시간 동안 객체에 액세스하는 스레드에 편향시키는 것입니다. 소유자 스레드는 자신이 소유한 객체에 대해 비원자적 참조 계산을 수행할 수 있는 반면, 다른 스레드는 이러한 객체에 대해 원자적 참조 계산을 수행해야 합니다. 이러한 접근 방식은 대부분의 객체가 대부분의 시간 동안 하나의 스레드에 의해서만 액세스되기 때문에 순수한 원자적 참조 계산보다 더 바람직합니다. 소유자 스레드가 비원자적 참조 계산을 수행하도록 허용하면 실행 오버헤드를 줄일 수 있습니다.

또한 True, False, 작은 정수 및 일부 임시 문자열과 같이 일반적으로 사용되는 일부 Python 객체는 영구적입니다. 여기서 ‘영구적’이란 객체가 프로그램의 수명 동안 보존되므로 참조 계산이 필요하지 않다는 것을 의미합니다.
가비지 컬렉션
가비지 컬렉션이 수행되는 방식도 수정해야 합니다. 참조가 해제될 때 참조 카운트는 즉시 감소하지 않으며, 참조 카운트가 0에 도달해도 객체는 즉시 제거되지 않습니다. 대신 ‘지연 참조 계산’이라는 기법이 사용됩니다.
참조 카운트를 감소시켜야 하는 경우, 객체는 테이블에 저장되고 이후 두 번 검사되어 참조 카운트가 올바르게 감소했는지 확인합니다. 이를 통해 GIL을 사용하지 않으면 참조 계산이 GIL만큼 직관적이지 않기 때문에 참조 중인 객체가 조기에 제거되는 일이 발생하지 않습니다. 이렇게 되면 가비지 컬렉션 프로세스가 더 복잡해집니다. 가비지 컬렉션이 각 스레드의 자체 참조 카운트를 얻기 위해 각 스레드의 스택을 탐색해야 할 수도 있기 때문입니다.
고려해야 할 또 다른 사항은 가비지 컬렉션 중에 참조 카운트가 안정적으로 유지되어야 한다는 것입니다. 객체가 삭제되려고 할 때 갑자기 참조되는 경우 심각한 문제가 발생할 수 있습니다. 따라서 가비지 컬렉션 주기 동안 스레드 안전을 보장하기 위해 ‘세계를 멈춰야’ 합니다.
메모리 할당
GIL을 사용하여 스레드 안전을 보장하는 경우 Python의 내부 메모리 할당자 pymalloc이 사용됩니다. 하지만 GIL이 없다면 새로운 메모리 할당자가 필요할 것입니다. Sam Gross는 Daan Leijen이 만들고 Microsoft에서 유지 관리하는 범용 할당자인 mimalloc을 PEP에서 제안했습니다. 이 방법은 스레드로부터 안전하고 작은 객체에서도 양호한 성능을 발휘하므로 좋은 선택입니다.
Mimalloc은 힙을 페이지로 채우고 페이지 블록으로 채웁니다. 각 페이지에는 블록이 포함되어 있으며, 각 페이지 내의 블록 크기는 모두 동일합니다. 목록과 사전 액세스에 몇 가지 제한을 추가함으로써 가비지 컬렉터는 모든 객체를 찾기 위해 연결 목록을 유지할 필요가 없으며, 잠금을 획득하지 않고도 목록과 사전에 대한 읽기 액세스를 허용합니다.

GIL 제거에 대해 더 깊이 있는 내용들이 많지만 여기서 모두 다루는 것은 불가능합니다. 자세한 내용은 PEP 703 – CPython에서 전역 인터프리터 잠금을 선택 사항으로 만들기에서 확인할 수 있습니다.
GIL이 있는 경우와 없는 경우의 성능 차이
Python 3.13은 자유 스레드 옵션을 제공하므로 표준 Python 3.13 버전과 자유 스레드 버전의 성능을 비교할 수 있습니다.
자유 스레드 Python 설치
pyenv를 사용하여 두 버전을 설치합니다. 표준 버전(예: 3.13.5)과 자유 스레드 버전(예: 3.13.5t)입니다.
또는 Python.org에서 설치 프로그램을 사용할 수도 있습니다. 자유 스레드 Python을 설치하려면 설치하는 동안 Customize(사용자 지정) 옵션을 선택하고 추가 상자를 선택하세요(이 블로그 글의 예시 참조).
두 버전을 모두 설치하면 PyCharm 프로젝트에 인터프리터로 추가할 수 있습니다.
먼저, 오른쪽 하단에 있는 Python 인터프리터의 이름을 클릭합니다.

메뉴에서 Add New Interpreter(새 인터프리터 추가)를 선택한 다음, Add Local Interpreter(지역 인터프리터 추가)를 선택합니다.

Select existing(기존 항목 선택)을 선택하고 인터프리터 경로가 로드될 때까지 기다립니다(저처럼 인터프리터가 많으면 시간이 좀 걸릴 수 있습니다). 그런 다음 Python path(Python 경로) 드롭다운 메뉴에서 방금 설치한 새 인터프리터를 선택합니다.

OK(확인)를 클릭하여 추가합니다. 다른 인터프리터에도 같은 단계를 반복합니다. 이제 오른쪽 하단에 있는 인터프리터 이름을 다시 클릭하면 위의 이미지와 같이 여러 개의 Python 3.13 인터프리터가 표시됩니다.
CPU 집약적 프로세스로 테스트
다음으로, 여러 버전을 테스트하기 위한 스크립트가 필요합니다. 이 블로그 시리즈의 1부에서 CPU 집약적 프로세스의 속도를 높이려면 진정한 멀티스레딩이 필요하다고 설명했습니다. GIL을 제거하여 진정한 멀티스레딩이 실행되고 Python이 더 빨라지는지 확인하려면 여러 스레드에서 CPU를 많이 사용하는 프로세스로 테스트해 볼 수 있습니다. 제가 Junie에게 생성해달라고 한 스크립트는 다음과 같습니다(최종적으로 몇 가지를 수정했음).
import time
import multiprocessing # Kept for CPU count
from concurrent.futures import ThreadPoolExecutor
import sys
def is_prime(n):
"""Check if a number is prime (CPU-intensive operation)."""
if n <= 1:
return False
if n <= 3:
return True
if n % 2 == 0 or n % 3 == 0:
return False
i = 5
while i * i <= n:
if n % i == 0 or n % (i + 2) == 0:
return False
i += 6
return True
def count_primes(start, end):
"""Count prime numbers in a range."""
count = 0
for num in range(start, end):
if is_prime(num):
count += 1
return count
def run_single_thread(range_size, num_chunks):
"""Run the prime counting task in a single thread."""
chunk_size = range_size // num_chunks
total_count = 0
start_time = time.time()
for i in range(num_chunks):
start = i * chunk_size + 1
end = (i + 1) * chunk_size + 1 if i < num_chunks - 1 else range_size + 1
total_count += count_primes(start, end)
end_time = time.time()
return total_count, end_time - start_time
def thread_task(start, end):
"""Task function for threads."""
return count_primes(start, end)
def run_multi_thread(range_size, num_threads, num_chunks):
"""Run the prime counting task using multiple threads."""
chunk_size = range_size // num_chunks
total_count = 0
start_time = time.time()
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = []
for i in range(num_chunks):
start = i * chunk_size + 1
end = (i + 1) * chunk_size + 1 if i < num_chunks - 1 else range_size + 1
futures.append(executor.submit(thread_task, start, end))
for future in futures:
total_count += future.result()
end_time = time.time()
return total_count, end_time - start_time
def main():
# Fixed parameters
range_size = 1000000 # Range of numbers to check for primes
num_chunks = 16 # Number of chunks to divide the work into
num_threads = 4 # Fixed number of threads for multi-threading test
print(f"Python version: {sys.version}")
print(f"CPU count: {multiprocessing.cpu_count()}")
print(f"Range size: {range_size}")
print(f"Number of chunks: {num_chunks}")
print("-" * 60)
# Run single-threaded version as baseline
print("Running single-threaded version (baseline)...")
count, single_time = run_single_thread(range_size, num_chunks)
print(f"Found {count} primes in {single_time:.4f} seconds")
print("-" * 60)
# Run multi-threaded version with fixed number of threads
print(f"Running multi-threaded version with {num_threads} threads...")
count, thread_time = run_multi_thread(range_size, num_threads, num_chunks)
speedup = single_time / thread_time
print(f"Found {count} primes in {thread_time:.4f} seconds (speedup: {speedup:.2f}x)")
print("-" * 60)
# Summary
print("SUMMARY:")
print(f"{'Threads':<10} {'Time (s)':<12} {'Speedup':<10}")
print(f"{'1 (baseline)':<10} {single_time:<12.4f} {'1.00x':<10}")
print(f"{num_threads:<10} {thread_time:<12.4f} {speedup:.2f}x")
if __name__ == "__main__":
main()
다양한 Python 인터프리터를 사용하여 스크립트를 더 쉽게 실행할 수 있도록 PyCharm 프로젝트에 사용자 지정 실행 스크립트를 추가할 수 있습니다.
맨 위에서 Run(실행) 버튼(
) 옆에 있는 드롭다운 메뉴에서 Edit Configurations(구성 편집)…을 선택합니다.

왼쪽 상단에 있는 + 버튼을 클릭하고 Add New Configuration(새 구성 추가) 드롭다운 메뉴에서 Python을 선택합니다.

어떤 인터프리터가 사용되고 있는지 알 수 있는 이름을 선택합니다(예를 들어 3.13.5와 3.15.3t). 아래에 표시된 대로 올바른 인터프리터를 선택하고 테스트 스크립트 이름을 추가합니다.

각 인터프리터에 대해 하나씩 두 개의 구성을 추가합니다. 그런 다음 OK(확인)를 클릭합니다.
이제 구성을 선택하고 상단의 Run(실행) 버튼(
)을 클릭하면 GIL을 포함하거나 포함하지 않고 테스트 스크립트를 쉽게 선택하고 실행할 수 있습니다.
결과 비교
GIL이 사용된 표준 버전 3.13.5를 실행하면 다음과 같은 결과가 나옵니다.
Python version: 3.13.5 (main, Jul 10 2025, 20:33:15) [Clang 17.0.0 (clang-1700.0.13.5)] CPU count: 8 Range size: 1000000 Number of chunks: 16 ------------------------------------------------------------ Running single-threaded version (baseline)... Found 78498 primes in 1.1930 seconds ------------------------------------------------------------ Running multi-threaded version with 4 threads... Found 78498 primes in 1.2183 seconds (speedup: 0.98x) ------------------------------------------------------------ SUMMARY: Threads Time (s) Speedup 1 (baseline) 1.1930 1.00x 4 1.2183 0.98x
보시다시피, 4 스레드 버전을 실행할 때와 기준인 단일 스레드를 실행할 때 속도에 큰 변화가 없습니다. 자유 스레드 버전 3.13.5t를 실행하면 어떤 결과가 나오는지 살펴보겠습니다.
Python version: 3.13.5 experimental free-threading build (main, Jul 10 2025, 20:36:28) [Clang 17.0.0 (clang-1700.0.13.5)] CPU count: 8 Range size: 1000000 Number of chunks: 16 ------------------------------------------------------------ Running single-threaded version (baseline)... Found 78498 primes in 1.5869 seconds ------------------------------------------------------------ Running multi-threaded version with 4 threads... Found 78498 primes in 0.4662 seconds (speedup: 3.40x) ------------------------------------------------------------ SUMMARY: Threads Time (s) Speedup 1 (baseline) 1.5869 1.00x 4 0.4662 3.40x
이번에는 속도가 이전보다 3배 이상 빨라졌습니다. 두 경우 모두 멀티스레딩의 오버헤드가 있다는 점에 유의하세요. 따라서 진정한 멀티스레딩을 사용하더라도 4개 스레드에서 4배의 속도가 나오지는 않습니다.
결론
Faster Python 블로그 시리즈 2부에서는 과거에 Python GIL을 사용했던 이유, 멀티프로세싱을 사용하여 GIL의 한계를 우회하는 방법, 그리고 GIL을 제거하는 프로세스와 효과에 대해 논의했습니다.
이 블로그 게시물을 작성할 당시 Python의 자유 스레드 버전은 여전히 기본 버전이 아니었습니다. 하지만 커뮤니티와 타사 라이브러리의 채택으로 인해 앞으로는 자유 스레드 버전의 Python이 표준이 될 것으로 커뮤니티에서는 기대하고 있습니다. 발표에 따르면, Python 3.14에는 실험 단계를 넘어서는 자유 스레드 버전이 포함될 예정이지만, 이는 선택 사항으로 남을 것입니다.
PyCharm은 속도와 정확성을 보장하며 최고 수준의 Python 지원을 제공합니다. 모든 코딩 요구 사항에 대해 가장 스마트한 코드 완성, PEP 8 준수, 스마트 리팩터링 및 검사 기능을 활용하세요. 이 블로그 게시물에서 보여 드렸듯이 PyCharm은 Python 인터프리터와 실행 구성에 대한 사용자 지정 설정을 제공하므로 몇 번의 클릭만으로 다양한 Python 프로젝트에 맞게 인터프리터를 전환할 수 있습니다.
Subscribe to PyCharm Blog updates
Discover more
更快的 Python:解开 Python 全局解释器锁
什么是 Python 的全局解释器锁 (GIL)?
“全局解释器锁”(或 GIL)是 Python 社区中的常见术语。 这是一个众所周知的 Python 功能。 但 GIL 到底是什么?
如果您有使用其他编程语言(例如 Rust)的经验,您可能已经知道什么是互斥锁 (mutex)。 互斥锁可以确保数据每次只能由一个线程访问。 这可以防止数据被多个线程同时修改。 您可以把它视为一种“锁”,它会阻止所有线程访问数据,除了持有密钥的线程之外。
GIL 基本上是一个互斥锁。 它一次只允许一个线程访问 Python 解释器。 我有时把它想象成 Python 的方向盘。 您肯定不会想让多个人操控方向盘! 但话说回来,一群人旅行时经常会换司机。 这就有点像把解释器访问权限交给另一个线程。
由于 GIL,Python 不允许真正的多线程进程。 这项功能在过去十年中引发了争议,并且有很多尝试通过移除 GIL 和允许多线程进程来提高 Python 的速度。 最近在 Python 3.13 中,引入了一种无需 GIL 即可使用 Python 的选项,有时也称为无 GIL 或自由线程 Python。 由此,Python 编程的新时代开始了。
为什么 GIL 最开始会出现?
既然 GIL 这么不受欢迎,那当初为什么要实现它呢? 拥有 GIL 其实有很多好处。 在其他具有真正多线程处理的编程语言中,有时问题源自多个线程修改数据,最终结果取决于哪个线程或进程首先完成。 这被称为“竞争条件”。 像 Rust 这样的语言通常很难学习,因为程序员必须使用互斥锁防止竞争条件。
在 Python 中,所有对象都有一个引用计数器来跟踪有多少其他对象需要从它们获取信息。 如果引用计数器达到零,因为我们知道由于 GIL,Python 中不存在竞争条件,我们可以放心地声明该对象不再需要并且可以作为垃圾被回收。
当 Python 在 1991 年首次发布时,大多数个人计算机只有一个核心,并且没有多少程序员要求多线程处理支持。 拥有 GIL 可以解决程序实现中的很多问题,同时还使代码易于维护。 因此,Python 的创造者 Guido van Rossum 于 1992 年添加了 GIL。
快进到 2025 年:个人计算机拥有多核处理器,因此计算能力更强。 我们可以利用额外的算力实现真正的并发,而无需摆脱 GIL。
在本文的后面,我们将分解说明移除它的流程。 现在,我们先看看在 GIL 存在的情况下真正的并发是如何运作的。
Python 中的多进程处理
在深入探究移除 GIL 的流程之前,我们先看看 Python 开发者如何使用 multiprocessing 库实现真正的并发。 multiprocessing 标准库提供本地和远程并发,使用子进程而不是线程有效避开全局解释器锁。 这样一来,multiprocessing 模块允许程序员充分利用特定机器上的多个处理器。
不过,要执行多进程处理,我们必须以稍微不同的方式设计程序。 下面的示例展示了如何在 Python 中使用 multiprocessing 库。
还记得我们博客系列第 1 部分中的异步汉堡店吗?
import asyncio
import time
async def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
async def main():
order_queue = []
for i in range(3):
order_queue.append(make_burger(i))
await asyncio.gather(*(order_queue))
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
我们可以使用 multiprocessing 库来做同样的事,例如:
import multiprocessing
import time
def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
if __name__ == "__main__":
print("Number of available CPU:", multiprocessing.cpu_count())
s = time.perf_counter()
all_processes = []
for i in range(3):
process = multiprocessing.Process(target=make_burger, args=(i,))
process.start()
all_processes.append(process)
for process in all_processes:
process.join()
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
您可能还记得,multiprocessing 中的许多方法与 threading 非常相似。 为了了解 multiprocessing 的区别,我们来探索一个更复杂的用例:
import multiprocessing
import time
import queue
def make_burger(order_num, item_made):
name = multiprocessing.current_process().name
print(f"{name} is preparing burger #{order_num}...")
time.sleep(5) # time for making burger
item_made.put(f"Burger #{order_num}")
print(f"Burger #{order_num} made by {name}")
def make_fries(order_num, item_made):
name = multiprocessing.current_process().name
print(f"{name} is preparing fries #{order_num}...")
time.sleep(2) # time for making fries
item_made.put(f"Fries #{order_num}")
print(f"Fries #{order_num} made by {name}")
def working(task_queue, item_made, order_num, lock):
break_count = 0
name = multiprocessing.current_process().name
while True:
try:
task = task_queue.get_nowait()
except queue.Empty:
print(f"{name} has nothing to do...")
if break_count > 1:
break # stop if idle for too long
else:
break_count += 1
time.sleep(1)
else:
lock.acquire()
try:
current_num = order_num.value
order_num.value = current_num + 1
finally:
lock.release()
task(current_num, item_made)
break_count = 0
if __name__ == "__main__":
print("Welcome to Pyburger! Please place your order.")
burger_num = input("Number of burgers:")
fries_num = input("Number of fries:")
s = time.perf_counter()
task_queue = multiprocessing.Queue()
item_made = multiprocessing.Queue()
order_num = multiprocessing.Value("i", 0)
lock = multiprocessing.Lock()
for i in range(int(burger_num)):
task_queue.put(make_burger)
for i in range(int(fries_num)):
task_queue.put(make_fries)
staff1 = multiprocessing.Process(
target=working,
name="John",
args=(
task_queue,
item_made,
order_num,
lock,
),
)
staff2 = multiprocessing.Process(
target=working,
name="Jane",
args=(
task_queue,
item_made,
order_num,
lock,
),
)
staff1.start()
staff2.start()
staff1.join()
staff2.join()
print("All tasks finished. Closing now.")
print("Items created are:")
while not item_made.empty():
print(item_made.get())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
这是我们得到的输出:
Welcome to Pyburger! Please place your order. Number of burgers:3 Number of fries:2 Jane has nothing to do... John is preparing burger #0... Jane is preparing burger #1... Burger #0 made by John John is preparing burger #2... Burger #1 made by Jane Jane is preparing fries #3... Fries #3 made by Jane Jane is preparing fries #4... Burger #2 made by John John has nothing to do... Fries #4 made by Jane Jane has nothing to do... John has nothing to do... Jane has nothing to do... John has nothing to do... Jane has nothing to do... All tasks finished. Closing now. Items created are: Burger #0 Burger #1 Fries #3 Burger #2 Fries #4 Orders completed in 12.21 seconds.
请注意,multiprocessing 中存在一些限制,导致上述代码以这种方式设计。 接下来我们逐一分析。
首先,记住我们之前有 make_burger 和 make_fries 函数来生成具有正确 order_num 的函数:
def make_burger(order_num):
def making_burger():
logger.info(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making burger
logger.info(f"Burger made #{order_num}")
return making_burger
def make_fries(order_num):
def making_fries():
logger.info(f"Preparing fries #{order_num}...")
time.sleep(2) # time for making fries
logger.info(f"Fries made #{order_num}")
return making_fries
使用 multiprocessing 时我们不能执行同样的操作。 如果尝试,会导致如下错误:
AttributeError: Can't get local object 'make_burger..making_burger'
原因是 multiprocessing 使用 pickle,它通常只能序列化顶层模块函数。 这是 multiprocessing 的局限之一。
其次,注意上面使用 multiprocessing 的示例代码段,我们没有对共享数据使用任何全局变量。 例如,我们不能对 item_made 和 order_num 使用全局变量。 要在不同的进程之间共享数据,multiprocessing 库中的特殊类对象(如 Queue 和 Value)会被使用并作为实参传递给进程。
一般来说,不建议在不同进程之间共享数据和状态,因为这会导致更多问题。 在上面的例子中,我们必须使用 Lock 确保 order_num 的值每次只能被一个进程访问和递增。 如果没有 Lock,商品的订单号可能会变成这样:
Items created are: Burger #0 Burger #0 Fries #2 Burger #1 Fries #3
以下是使用锁来避免麻烦的方式:
lock.acquire()
try:
current_num = order_num.value
order_num.value = current_num + 1
finally:
lock.release()
task(current_num, item_made)
要详细了解如何使用 multiprocessing 标准库,您可以在这里阅读文档。
移除 GIL
近十年来,移除 GIL 一直是一个话题。 2016 年,在 Python Language Summit 上,Larry Hastings 讲解了他对 CPython 解释器进行“GIL 切除”的想法以及他在这一想法上取得的进展[1]。 这是移除 Python GIL 的一次开创性尝试。 2021 年,Sam Gross 重新引发了关于移除 GIL 的讨论[2],并催生了 2023 年发布的 PEP 703 – Making the Global Interpreter Lock Optional in CPython。
可以看到,移除 GIL 绝不是一个仓促的决定,并且已经在社区内引起很多争论。 如上面的多进程处理示例(以及上面链接的 PEP 703)所示,当 GIL 提供的保证被移除时,情况很快就会变得复杂起来。
[1]: https://lwn.net/Articles/689548/
[2]: https://lwn.net/ml/python-dev/CAGr09bSrMNyVNLTvFq-h6t38kTxqTXfgxJYApmbEWnT71L74-g@mail.gmail.com/
引用计数
存在 GIL 时,引用计数和垃圾回收更加直观。 当一次只有一个线程可以访问 Python 对象时,我们可以依靠简单的非原子引用计数,并在引用计数达到零时移除对象。
移除 GIL 让情况有些棘手。 我们不能再使用非原子引用计数,因为这不能保证线程安全。 如果多个线程同时对 Python 对象执行引用的多次递增和递减,则可能出现问题。 理想情况下,原子引用计数将用于保证线程安全。 但这种方法开销较大,当线程较多时效率会受到影响。
解决方案是使用偏向引用计数,这也保证了线程安全。 这里的想法是将每个对象偏向于所有者线程,即大多数时间访问该对象的线程。 所有者线程可以对其拥有的对象执行非原子引用计数,其他线程则需要对这些对象执行原子引用计数。 这个方法比单纯的原子引用计数更可取,因为大多数对象大多数时间仅由一个线程访问。 我们可以通过允许所有者线程执行非原子引用计数来减少执行开销。

此外,一些常用的 Python 对象,例如 True、False、小整数和一些暂存字符串,都是持久的。 这里的“持久”表示对象将在程序的整个生存期内保留,因此不需要引用计数。
垃圾回收
我们还必须修改垃圾回收完成的方式。 引用被释放时,不是立即减少引用计数,引用计数达到零时,也不是立即移除对象,而是使用一种名为“延迟引用计数”的技术。
需要减少引用计数时,对象被存储在一个表中,表后续将接受双重检查以确定引用计数的递减是否准确。 这避免了在其被引用时过早移除对象,这种情况在没有 GIL 的情况下可能会发生,因为引用计数并不像 GIL 那样直观。 这使垃圾回收过程更为复杂,因为垃圾回收可能需要遍历每个线程的堆栈以获取每个线程自己的引用计数。
另一件需要考虑的事:垃圾回收期间引用计数需要保持稳定。 如果一个对象即将被丢弃但突然被引用,这将导致严重的问题。 因此,在垃圾回收周期中,它必须“停止整个世界”来提供线程安全保证。
内存分配
有 GIL 保证线程安全时,将使用 Python 内部内存分配器 pymalloc。 但是没有 GIL,我们将需要一个新的内存分配器。 Sam Gross 在 PEP 中提出了 mimalloc,这是由 Daan Leijen 创建并由 Microsoft 维护的通用分配器。 这是一个很好的选择,因为它线程安全,并且在小对象上性能良好。
Mimalloc 使用页面填充其堆,使用块填充页面。 每个页面都包含块,并且每个页面内的块大小均相同。 对列表和字典访问添加一些限制后,垃圾回收器不必维护关联列表即可查找所有对象,并且还可以在不获取锁的情况下对列表和字典进行读取访问。

关于移除 GIL 还有更多细节,但这里不可能全部涵盖。 您可以查看 PEP 703 – Making the Global Interpreter Lock Optional in CPython 获得完整分析。
有无 GIL 的性能差异
由于 Python 3.13 提供了自由线程选项,我们可以将 Python 3.13 标准版本的性能与自由线程版本进行比较。
安装自由线程 Python
我们将使用 pyenv 安装两个版本:标准版(例如 3.13.5)和自由线程版(例如 3.13.5t)。
或者,您也可以使用 Python.org 上的安装程序。 确保在安装过程中选择 Customize(自定义)选项,并选中可选框以安装自由线程 Python(参见这篇博文中的示例)。
安装两个版本后,我们可以将它们添加为 PyCharm 项目中的解释器。
首先,点击右下角的 Python 解释器的名称。

在菜单中选择 Add New Interpreter(添加新解释器),然后选择 Add Local Interpreter(添加本地解释器)。

选择 Select existing(选择现有),等待解释器路径加载(如果您像我一样有很多解释器,这可能需要一段时间),然后从下拉菜单 Python path(Python 路径)中选择刚刚安装的新解释器。

点击 OK(确定)来添加。 对另一个解释器重复相同的步骤。 现在,再次点击右下角的解释器名称时,您将看到多个 Python 3.13 解释器,就像上图一样。
使用受 CPU 限制的进程进行测试
接下来,我们需要一个脚本来测试不同的版本。 我们在本博文系列的第 1 部分中解释过,为了加快受 CPU 限制的进程的速度,我们需要真正的多线程处理。 为了查看移除 GIL 是否能够实现真正的多线程处理并使 Python 更快,我们可以在多个线程上使用受 CPU 限制的进程进行测试。 这是我让 Junie 生成的脚本(我做了一些最终调整):
import time
import multiprocessing # Kept for CPU count
from concurrent.futures import ThreadPoolExecutor
import sys
def is_prime(n):
"""Check if a number is prime (CPU-intensive operation)."""
if n <= 1:
return False
if n <= 3:
return True
if n % 2 == 0 or n % 3 == 0:
return False
i = 5
while i * i <= n:
if n % i == 0 or n % (i + 2) == 0:
return False
i += 6
return True
def count_primes(start, end):
"""Count prime numbers in a range."""
count = 0
for num in range(start, end):
if is_prime(num):
count += 1
return count
def run_single_thread(range_size, num_chunks):
"""Run the prime counting task in a single thread."""
chunk_size = range_size // num_chunks
total_count = 0
start_time = time.time()
for i in range(num_chunks):
start = i * chunk_size + 1
end = (i + 1) * chunk_size + 1 if i < num_chunks - 1 else range_size + 1
total_count += count_primes(start, end)
end_time = time.time()
return total_count, end_time - start_time
def thread_task(start, end):
"""Task function for threads."""
return count_primes(start, end)
def run_multi_thread(range_size, num_threads, num_chunks):
"""Run the prime counting task using multiple threads."""
chunk_size = range_size // num_chunks
total_count = 0
start_time = time.time()
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = []
for i in range(num_chunks):
start = i * chunk_size + 1
end = (i + 1) * chunk_size + 1 if i < num_chunks - 1 else range_size + 1
futures.append(executor.submit(thread_task, start, end))
for future in futures:
total_count += future.result()
end_time = time.time()
return total_count, end_time - start_time
def main():
# Fixed parameters
range_size = 1000000 # Range of numbers to check for primes
num_chunks = 16 # Number of chunks to divide the work into
num_threads = 4 # Fixed number of threads for multi-threading test
print(f"Python version: {sys.version}")
print(f"CPU count: {multiprocessing.cpu_count()}")
print(f"Range size: {range_size}")
print(f"Number of chunks: {num_chunks}")
print("-" * 60)
# Run single-threaded version as baseline
print("Running single-threaded version (baseline)...")
count, single_time = run_single_thread(range_size, num_chunks)
print(f"Found {count} primes in {single_time:.4f} seconds")
print("-" * 60)
# Run multi-threaded version with fixed number of threads
print(f"Running multi-threaded version with {num_threads} threads...")
count, thread_time = run_multi_thread(range_size, num_threads, num_chunks)
speedup = single_time / thread_time
print(f"Found {count} primes in {thread_time:.4f} seconds (speedup: {speedup:.2f}x)")
print("-" * 60)
# Summary
print("SUMMARY:")
print(f"{'Threads':<10} {'Time (s)':<12} {'Speedup':<10}")
print(f"{'1 (baseline)':<10} {single_time:<12.4f} {'1.00x':<10}")
print(f"{num_threads:<10} {thread_time:<12.4f} {speedup:.2f}x")
if __name__ == "__main__":
main()
为了更轻松地使用不同的 Python 解释器运行脚本,我们可以向 PyCharm 项目添加自定义运行脚本。
在顶部,从 Run(运行)按钮 (
) 旁边的下拉菜单中选择 Edit Configurations…(编辑配置…)。

点击左上角的 + 按钮,然后从 Add New Configuration(添加新配置)下拉菜单中选择 Python。

选择一个名称,以便自己知道正在使用具体哪个解释器,例如 3.13.5 与 3.15.3t。 选择正确的解释器并添加测试脚本的名称,如下所示:

添加两个配置,每个解释器一个。 然后,点击 OK(确定)。
现在,我们可以选择配置并点击顶部的 Run(运行)按钮 (
) 轻松选择并运行带有或不带有 GIL 的测试脚本。
比较结果
这是我运行带有 GIL 的标准版本 3.13.5 时得到的结果:
Python version: 3.13.5 (main, Jul 10 2025, 20:33:15) [Clang 17.0.0 (clang-1700.0.13.5)] CPU count: 8 Range size: 1000000 Number of chunks: 16 ------------------------------------------------------------ Running single-threaded version (baseline)... Found 78498 primes in 1.1930 seconds ------------------------------------------------------------ Running multi-threaded version with 4 threads... Found 78498 primes in 1.2183 seconds (speedup: 0.98x) ------------------------------------------------------------ SUMMARY: Threads Time (s) Speedup 1 (baseline) 1.1930 1.00x 4 1.2183 0.98x
可以看到,与单线程基线相比,运行 4 个线程的版本时速度没有显著变化。 我们看看运行自由线程版本 3.13.5t 时会得到什么:
Python version: 3.13.5 experimental free-threading build (main, Jul 10 2025, 20:36:28) [Clang 17.0.0 (clang-1700.0.13.5)] CPU count: 8 Range size: 1000000 Number of chunks: 16 ------------------------------------------------------------ Running single-threaded version (baseline)... Found 78498 primes in 1.5869 seconds ------------------------------------------------------------ Running multi-threaded version with 4 threads... Found 78498 primes in 0.4662 seconds (speedup: 3.40x) ------------------------------------------------------------ SUMMARY: Threads Time (s) Speedup 1 (baseline) 1.5869 1.00x 4 0.4662 3.40x
这次的速度是之前的 3 倍多。 请注意,两种情况下都会有多线程处理的开销。 因此,即使是真正的多线程处理,运行 4 个线程时的速度也不会是 4 倍。
结论
在《更快的 Python》博文系列的第 2 部分中,我们讨论了过去使用 Python GIL 的原因、使用 multiprocessing 规避 GIL 的限制,以及移除 GIL 的流程和效果。
截至这篇博文发布,Python 的自由线程版本仍然不是默认版本。 不过,随着社区和第三方库的采用,社区预计 Python 的自由线程版本将在未来成为标准。 根据公布的消息,Python 3.14 将包含一个自由线程版本,该版本将度过实验阶段,但仍为可选。
PyCharm 提供了一流的 Python 支持,可以确保速度和准确性。 受益于最智能的代码补全、PEP 8 合规性检查、智能重构和各种检查,满足所有编码需求。 如本文所示,PyCharm 为 Python 解释器和运行配置提供了自定义设置,只需点击几下即可在解释器之间切换,这使其适用于各种 Python 项目。
本博文英文原作者:
