---
title: "グリーンスレッドとは？OSスレッドとの違いとN:1モデルの制約を実測で解説"
url: "https://www.issoh.co.jp/tech/details/6332/"
published: 2025-04-16
updated: 2026-09-17
categories: ["Java"]
publisher: "株式会社一創"
---

# グリーンスレッドとは？OSスレッドとの違いとN:1モデルの制約を実測で解説

グリーンスレッド（green threads）は、OSのカーネルではなく、言語のランタイムやライブラリがスケジュールするユーザーモードのスレッドです。本記事では主に、複数の処理を1本のOSスレッドで動かすN:1方式を扱います。ただし、グリーンスレッドにはM:N方式の実装もあります。起動が軽く、本数も増やしやすい一方で、1本がブロックすると全体が止まり、マルチコアも使えません。

この記事では、N:1・1:1・M:Nの3つのスレッドモデルでグリーンスレッドの位置を整理し、JavaやRubyがグリーンスレッドを手放した経緯と、goroutineや仮想スレッドとの関係を説明します。N:1モデルの制約は、Pythonの最小スケジューラとgeventを実際に動かして確かめ、その出力も掲載しています。

## まとめ：グリーンスレッドの要点と選び方

- **定義**：OSではなくランタイムがスケジュールするスレッドです。初期のJavaやCRuby 1.8の実装は、複数のスレッドを1本のOSスレッドに載せる**N:1（M:1）モデル**でした。一方、1.0以前のRustのようにM:Nで動かす実装もありました。
- **強み**：起動と切り替えが軽いことです。macOSでの実測では、2,000本の起動にかかった時間はOSスレッドの約6分の1でした。
- **弱み**：N:1方式では、OSスレッド自体をブロックする呼び出しが他の処理も止めます。geventのような協調的実装では、制御を譲らないCPU処理も他の処理を待たせます。1本のOSスレッドで動くので、マルチコアも使えません。
- **歴史**：初期のJava（Solaris版）とCRuby 1.8はN:1でしたが、どちらもOSスレッドとの1:1対応へ移行しました。Rustも1.0より前にグリーンスレッドを標準ライブラリから外しています。
- **現在**：goroutine、Javaの仮想スレッド、Erlangのプロセスは**M:N**モデルです。性能や障害を議論するときは、N:1のグリーンスレッドと区別して扱うのが正確です。
- **選び方**：I/O待ちが中心の処理ならユーザーモードのスレッドが有効です。ただし新しく採用するなら、N:1のライブラリよりM:Nのランタイム（Go、Java 21以降）を選ぶほうが安全です。

## グリーンスレッドの定義とOSスレッドとの違い

OSスレッドは、カーネルが生成し、カーネルのスケジューラが実行するCPUを割り当てます。切り替えはカーネルが行い、システムコールによる待機だけでなく、タイマ割り込みなどを契機とするプリエンプションでも発生します。グリーンスレッドはこの仕組みをユーザー空間で再現したもので、スタックやレジスタの退避と復元、次に動かす処理の選択を、ランタイムが自分で行います。

[JEP 444](https://openjdk.org/jeps/444)（Java 21で正式化された仮想スレッドの仕様）は、初期のJavaのグリーンスレッドを「Java’s green threads all shared one OS thread (M:1 scheduling)」と説明しています。複数のスレッドが1本のOSスレッドを共有する、という点がこの用語の核心です。

| 観点                | グリーンスレッド（N:1）    | OSスレッド（1:1）                 |
| ----------------- | ---------------- | --------------------------- |
| スケジュールする主体        | 言語ランタイム・ライブラリ    | OSカーネル                      |
| 切り替えの契機           | 実装依存（geventは協調的） | カーネルが割り込む（プリエンプティブ）         |
| マルチコアの利用          | 不可（OSスレッドが1本）    | 可能                          |
| OSスレッドをブロックする呼び出し | 同じOSスレッド上の処理が止まる | そのスレッドだけが止まる                |
| 本数の上限             | メモリ量に依存          | OS・資源制限に依存（記事の検証環境では4,096本） |

非同期処理全体の中でスレッド・コルーチン・イベントループがどう位置づけられるかは、[非同期処理とは？同期処理との違いから実装方式まで実装者目線で解説](/tech/details/13590/)で整理しています。仮想スレッドという用語の一般的な定義は[仮想スレッドの概要と基本的な定義について](/tech/details/4725/)で扱っています。

## N:1・1:1・M:Nのスレッドモデルとグリーンスレッドの位置

言語側のスレッドとOSスレッドの対応は、N:1・1:1・M:Nに大別できます。本記事ではN:1方式を中心に説明しますが、Rustの旧libgreenのようにM:N方式のグリーンスレッドもあります。

- **N:1**：N本のスレッドを1本のOSスレッドで動かします。切り替えは軽いものの、マルチコアを使えず、1本がブロックすると全体が止まります。
- **1:1**：言語のスレッド1本にOSスレッド1本を割り当てます。マルチコアを使え、ブロッキングの影響もそのスレッドだけで済みます。ただし、生成と切り替えのコストはOSスレッドと同じです。
- **M:N**：M本のスレッドをN本のOSスレッドに載せ替えながら動かします。N:1の軽さと1:1のマルチコア対応を両立できる反面、ランタイムの実装が最も複雑になります。

OSのスレッドライブラリ自体も1:1に落ち着いています。Linuxでは、glibc 2.3.2からNPTLが使えるようになり、LinuxThreadsはglibc 2.4でサポート対象から外れました。man7.orgのpthreads(7)は、この2つを「Both of these are so-called 1:1 implementations」と説明しています。Solarisは、Solaris 9で導入した新しいスレッドライブラリで、各スレッドに1つのLWPを割り当てる方式に切り替えました。

## 協調的な切り替えの仕組み｜Pythonで動かす最小スケジューラ

ここで示す協調的スケジューリングは、「処理が自分で制御を手放し、スケジューラが次の処理へ渡す」という流れです。Pythonのジェネレータを使うと、この流れを十数行で再現できます。`yield`が制御を譲る地点で、`run`がラウンドロビンで次のタスクを選ぶスケジューラにあたります。

```
from collections import deque

def worker(name, n):
    for i in range(n):
        print(f"{name}: {i}")
        yield  # ここで制御をスケジューラへ返す

def run(tasks):
    ready = deque(tasks)
    while ready:
        task = ready.popleft()
        try:
            next(task)
            ready.append(task)
        except StopIteration:
            pass

run([worker("A", 3), worker("B", 2)])
```

Python 3.14.6で実行した出力は次のとおりです。OSスレッドは1本のままで、AとBが交互に進んでいます。

```
A: 0
B: 0
A: 1
B: 1
A: 2
```

実際のライブラリは、ジェネレータの代わりに各スレッドのスタックとレジスタを退避・復元します。Pythonのgreenletは、C拡張でスタックを切り替えて同じことを実現しています。

## N:1モデルの制約を実測｜geventでのブロッキングとCPU占有

ここからは、greenletを土台にしたgeventで、N:1モデルの制約を実際に確かめます。[gevent公式ドキュメント](https://www.gevent.org/intro.html)は「The greenlets all run in the same OS thread and are scheduled cooperatively.」と説明しています。実行環境は、macOS 26.6.2、Intel Core i9-9880H、Python 3.14.6、gevent 26.9.0、greenlet 3.5.6です（geventとgreenletは2026年9月17日時点のPyPI最新版）。

### ブロッキング呼び出しによる全体停止

1秒待つ処理を3本同時に起動し、全体の所要時間を計ります。待ち方は、geventの`gevent.sleep`と標準ライブラリの`time.sleep`の2通りです。

```
import time
import gevent

def io_task(sleep):
    sleep(1)

def run(label, sleep):
    t = time.perf_counter()
    gevent.joinall([gevent.spawn(io_task, sleep) for _ in range(3)])
    print(f"{label}: {time.perf_counter() - t:.2f}s")

run("gevent.sleep", gevent.sleep)
run("time.sleep (パッチなし)", time.sleep)
```

```
gevent.sleep: 1.15s
time.sleep (パッチなし): 3.29s
```

繰り返し実行したところ、`gevent.sleep`は毎回1.15秒前後、`time.sleep`は3.29〜3.45秒でした。`time.sleep`はOSスレッドごと眠らせるため、他のgreenletに順番が回らず、3本が直列に実行されます。先頭で`gevent.monkey.patch_all()`を呼んで標準ライブラリを協調版に置き換えると、同じ`time.sleep`でも1.15秒で終わりました。裏を返すと、置き換えの対象外になるC拡張のブロッキングI/O（一部のDBドライバなど）が1つあるだけで、並行性は失われます。

Celeryのワーカーをgeventプールで動かす構成もこの前提の上にあります（[Celery（Python）とは：分散タスクキューの仕組みとブローカー選定・冪等性設計を解説](/tech/details/17293/)）。

### CPUを占有する処理による待ち

次は、0.1秒ごとに時刻を表示するgreenletと、CPUを使い続ける計算処理を同時に動かします。

```
import time
import gevent

def ticker():
    start = time.perf_counter()
    for _ in range(3):
        gevent.sleep(0.1)
        print(f"tick {time.perf_counter() - start:.2f}s")

def cpu_bound():
    sum(i * i for i in range(20_000_000))

gevent.joinall([gevent.spawn(ticker), gevent.spawn(cpu_bound)])
```

```
tick 4.31s
tick 4.56s
tick 4.81s
```

0.1秒後に出るはずの最初の表示が、4.31秒後になりました（6回の実行で4.07〜4.70秒）。計算処理は途中で制御を譲らないため、終わるまでスケジューラが動けません。協調的な切り替えでは、「すぐに終わらない処理はグリーンスレッドに載せない」という規律を守るのはアプリケーション側の責任です。

### 起動コストと本数の上限

2,000本のOSスレッドと2,000本のgreenletを起動し、所要時間と最大常駐メモリ（ru\_maxrss）の増分を3回ずつ比べました。どちらも生成前から計測を始め、起動した各スレッドはイベント待ちで止めています（OSスレッドは`threading.Event`、greenletは`gevent.event.Event`）。待機の実装が違うため、厳密な性能比ではなく傾向を見るための参考値です。

| 2,000本の起動         | 所要時間       | 最大常駐メモリの増分   |
| ----------------- | ---------- | ------------ |
| OSスレッド（threading） | 0.47〜0.53秒 | 30.9〜31.0MiB |
| greenlet（gevent）  | 0.06〜0.08秒 | 15.2〜15.3MiB |

起動時間は約6分の1になりましたが、メモリの差は約2倍にとどまりました。この比較は予約されたスタック領域ではなく、実際に常駐したメモリを測っています。スタックの使用量に加え、各実装の管理オブジェクトや待機処理も測定値に影響します。この環境で差を決めたのは、メモリよりも本数の上限でした。OSスレッドを増やし続けると、4,095本を起動した次の1本で`RuntimeError: can't start new thread`が発生しました。メインスレッドを含めると4,096本で、`sysctl kern.num_taskthreads`の値（4096）と一致します。greenletは10万本を起動でき、そのときの増分は747.5MiB（1本あたり約8KB）でした。

## Java・Ruby・Rustがグリーンスレッドを手放した経緯

### Java：Solaris版のM:1からネイティブスレッドへの移行

OracleのSolaris向け開発者ガイドは、「The initial implementation of Java threads on the Solaris system was many-to-one.」と記しています。Java 2 SDK v1.2のJNI FAQによると、Solaris版の`java`コマンドは既定でgreen threadsを使っていました。その後、Solaris 2.6以降のプラットフォームでは、性能向上のためにgreen threadsライブラリがSolarisのネイティブスレッドに置き換えられています。JEP 444はこの流れを、グリーンスレッドが「eventually outperformed by platform threads」、つまりOSスレッドのラッパーに性能で抜かれたとまとめています。

Java 21の仮想スレッドは、ユーザーモードのスレッドをM:Nで復活させたものです。スケジューラはFIFOモードのForkJoinPoolで、並列度は既定で利用可能なプロセッサ数です。使い方とpinningの注意点は[Java Virtual Thread（仮想スレッド）とProject Loomとは？作り方・仕組み・落とし穴を実務目線で解説](/tech/details/3209/)で解説しています。

### Ruby：1.8のM:1・1.9の1:1＋GVL・3.3のM:N導入

CRubyは1.8まで、複数のRubyスレッドを1本のネイティブスレッドで動かすM:1方式でした。Ruby 1.9でRubyスレッドとネイティブスレッドを1:1に対応させ、同時にGVL（Global VM Lock）を導入しています。その後、Ruby 3.0（2020年12月25日）でブロッキング処理を横取りするFiber Schedulerが入りました。Ruby 3.3ではM:Nスレッドスケジューラが追加されていますが、C拡張との互換性を壊すおそれがあるため、メインRactorでは既定で無効です。有効にするには環境変数`RUBY_MN_THREADS=1`を指定します。

### Rust：1.0公開前の組み込みランタイム削除

Rustは、2014年9月16日開始の[RFC 230](https://rust-lang.github.io/rfcs/0230-remove-runtime.html)で、標準ライブラリに組み込まれていたランタイムを削除し、グリーンスレッド実装のlibgreenをツリー外のパッケージへ移しました。RFC 230によると、当時のRustのグリーンスレッドはタスクをM:Nでスケジュールする方式でした。理由として挙げられたのは、ネイティブスレッドとグリーンスレッドに共通のI/O APIを強制する設計上の結合、バイナリサイズや動的ディスパッチのオーバーヘッド、ブロッキングI/Oとの相互運用の難しさ、保守の負担です。Rust 1.0（2015年5月15日）はグリーンスレッドを持たない状態で公開され、軽量な並行処理はasync/awaitとTokioなどの外部ランタイムが担うようになりました。

## goroutine・仮想スレッド・Erlangプロセスとグリーンスレッドの線引き

goroutineや仮想スレッドを「グリーンスレッド」と紹介する記事は少なくありません。ユーザーモードのスレッドという点では同じ仲間ですが、本記事の立場は、**設計や技術選定の文脈ではN:1の実装と区別すべき**というものです。JEP 444自身が初期JavaのM:1のグリーンスレッドとM:Nの仮想スレッドを分けて説明しており、両者ではマルチコアの利用とブロッキング時の挙動、つまりN:1の弱点そのものが違うからです。

| 実装                        | モデル | 切り替え                  | ブロッキング時                |
| ------------------------- | --- | --------------------- | ---------------------- |
| 初期Java（Solaris）・CRuby 1.8 | N:1 | ランタイムが制御              | 全体が止まる                 |
| gevent（greenlet）          | N:1 | 協調的                   | パッチ対象外なら全体が止まる         |
| Go goroutine              | M:N | 非同期プリエンプション（Go 1.14〜） | ランタイムが他のOSスレッドへ逃がす     |
| Java仮想スレッド                | M:N | 対応する待機操作でOSスレッドから外れる  | pinning・一部のブロッキング操作で占有 |
| Erlangプロセス                | M:N | reduction数でプリエンプト     | 他のスケジューラスレッドは動き続ける     |

- **goroutine**：Goコード用スタックの最小値`stackMin`は2,048バイトです。実際の初期割当量は、OS向けの追加領域やランタイムの調整により大きくなる場合があります。Go 1.14で非同期プリエンプションが入り、関数呼び出しを含まないループがスケジューラを止める問題が解消されました。基本は[ゴルーチン（Goroutine）とは？Go言語の並行処理を支える軽量スレッドの仕組みと使い方](/tech/details/4632/)、スケジューラの内部は[GoroutineのM:Nスケジューリング｜GMPモデルと切り替えコストの実測](/tech/details/4542/)を参照してください。
- **Erlangプロセス**：プロセスは一定量のreductionを消費するとプリエンプトされます。スケジューラスレッドの数は、既定で論理プロセッサ数です（`+S`オプション）。初期ヒープサイズは233ワードで、この領域にスタックも含まれます。

「グリーンスレッド」を広い意味での「ユーザーモードスレッド」として使うこと自体は誤りではありません。ただ、その呼び方をすると、ブロッキングで全体が止まる、マルチコアを使えない、という前提まで引き継いだように読まれます。性能や障害の議論では、N:1かM:Nかを明記するほうが誤解を生みません。

## グリーンスレッドを採用すべき場面と避けるべき場面

ユーザーモードのスレッドが効くのは、I/O待ちが大半を占める処理を大量に並べる場面です。実測でも、起動時間と本数の上限にははっきり差が出ました。一方、採用の可否はモデルによって変わります。

- **採用してよい**：数千本以上の接続を同時に待つサーバーやクローラーのうち、I/Oがすべて協調版に置き換わっていることを確認できたもの。既存の同期コードをほぼそのまま並行化したい場合は、geventのパッチ方式が近道です。
- **避けるべき**：CPUを使う処理が混ざるワークロード（前の計測では、他の処理が4秒以上待たされました）、パッチの効かないC拡張ドライバを使う構成、マルチコアで速くしたい計算処理。
- **新規採用ならM:Nを優先**：これから軽量な並行処理を設計するなら、N:1のライブラリより、Goや、Java 21以降の仮想スレッドのようなM:Nのランタイムを候補にすると、複数のOSスレッドを利用できます。ただし、CPU処理による実行枠の占有や、非対応のブロッキング処理への対策は別途必要です。Pythonでasyncio系を選ぶ場合の比較は[anyio・Trio・asyncioの違いと使い分け｜Python非同期ライブラリ比較](/tech/details/4346/)にまとめています。

## よくある質問

### グリーンスレッドとは簡単に言うと何ですか？

OSではなく言語のランタイムが切り替えを管理するスレッドです。初期のJavaなどのN:1実装では、OSから見えるのは1本のスレッドだけで、その中で複数の処理が順番に動きます。起動は軽い一方、OSスレッドをブロックする処理が1つあると全体が止まります。

### green threadとnative threadの違いは何ですか？

スケジュールする主体が違います。green threadはランタイムがユーザー空間で切り替え、native thread（OSスレッド）はカーネルが切り替えます。native threadはマルチコアを使えて、ブロッキングの影響もそのスレッドだけで済みます。

### Javaでグリーンスレッドが使われなくなったのはなぜですか？

初期のJava（Solaris版）のグリーンスレッドは、全スレッドが1本のOSスレッドを共有するM:1方式でした。JEP 444によると、OSスレッドのラッパーであるプラットフォームスレッドに性能で抜かれています。Solaris 2.6以降では、性能向上のためにネイティブスレッドへ置き換えられました。

### goroutineはグリーンスレッドですか？

ユーザーモードのスレッドという点では同じ仲間ですが、goroutineは複数のOSスレッドに載せ替えながら動くM:Nモデルです。初期のJavaやCRuby 1.8のように1本のOSスレッドに限られるN:1のグリーンスレッドとは、マルチコアの利用とブロッキング時の挙動が異なります。

### Javaの仮想スレッドはグリーンスレッドの復活ですか？

ユーザーモードのスレッドが戻ってきた、という意味では復活と言えます。ただし、仮想スレッドはM:Nでスケジュールされ、スケジューラの並列度は既定で利用可能なプロセッサ数です。M:1だった初期のグリーンスレッドとは構造が違います。

## 関連記事

- [Java Virtual Thread（仮想スレッド）とProject Loomとは？作り方・仕組み・落とし穴を実務目線で解説](/tech/details/3209/)
- [ゴルーチン（Goroutine）とは？Go言語の並行処理を支える軽量スレッドの仕組みと使い方](/tech/details/4632/)
- [GoroutineのM:Nスケジューリング｜GMPモデルと切り替えコストの実測](/tech/details/4542/)
- [仮想スレッドの概要と基本的な定義について](/tech/details/4725/)
- [非同期処理とは？同期処理との違いから実装方式まで実装者目線で解説](/tech/details/13590/)

---

出典: [グリーンスレッドとは？OSスレッドとの違いとN:1モデルの制約を実測で解説](<https://www.issoh.co.jp/tech/details/6332/>)（株式会社一創）
