go thread
pthread
前回まで goroutineを調べてきた(G-M-P)。が、その根底にあるOSのスレッドの事を 知らないので、調査してみる。
私の理解では、プロセスって、ulimit -a に表示されるリソース群の管理単位。 fork -> execve で、アプリがメモリーにロードされ、mainから実行を 開始する。CPUが複数有ったら、そこで重い計算処理とかを手分けしてやりたい。 それを可能にするのがスレッドだ。
アプリがメモリーにロードされているんで、そのメモリーの特定部分(関数)の 実行をそれぞれのCPUに担当させてしまえば良い。
その為のpthreadと言うライブラリィーが提供されてる。
20秒間スレッドが稼動。その後10秒間だけメインが居残るやつ。
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
void *th_func(void *arg) {
puts("thread created");
sleep(20);
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t th;
if (pthread_create(&th, NULL, th_func, NULL) == 0) {
puts("Main thread");
sleep(30);
}
}
これ、go的には、
main(){
go th_func()
time.Sleep(30 * time.Second)
}
コンパイルして実行。勿論OpenBSD上で動かす。だって、深い所まで容易に 潜って行けるから。まあ、Ubuntuでどうやればいいか、知らないだけだけど。
qe$ cc -g p.c -lpthread qe$ ./a.out & [1] 46724 qe$ Main thread thread created ps -aH ;; -HでスレッドIDも表示(リナだと -Lらしい) PID TID TT STAT TIME COMMAND 46724 491566 p1 S 0:00.08 ./a.out 46724 250749 p1 S 0:00.00 ./a.out 59565 178705 p1 R+pU 0:00.03 ps -aH qe$ ps -aH ;; 20秒経過するとメイン・スレッドだけになる。 PID TID TT STAT TIME COMMAND 46724 491566 p1 I 0:00.08 ./a.out 3835 266640 p1 R+pU 0:00.02 ps -aH
詳細な使用方法は、PTHREADS(3)
下記はユーザーランド側
(gdb) bt #0 pthread_create (threadp=.., attr=.., start_routine=.., arg=..) at /usr/src/lib/librthread/rthread.c:346 #1 0x00000516e5e2db0e in main (argc=1, argv=0x7576305e9a58) at p.c:16
そして、syscallする瞬間と渡したい引数(gdb調べ)
tib = _dl_allocate_tib(sizeof(*thread));
thread = tib->tib_thread;
thread->stack = _rthread_alloc_stack(thread);
:
/* we're going to be multi-threaded real soon now */
__isthreaded = 1;
=> rc = __tfork_thread(¶m, sizeof(param), _rthread_start, thread);
(gdb) p param
$4 = {
tf_tcb = 0x5191d421a00,
tf_tid = 0x5191d421a30,
tf_stack = 0x51993318800
}
(gdb) p _rthread_start
$5 = {void (void *)} 0x51976b01f10 <_rthread_start>
こちらはカーネル側
(gdb) bt 5
#0 cpu_fork (p1=.., p2=.., stack=.., tcb=.., func=.., arg=..)
at /usr/src/sys/arch/amd64/amd64/vm_machdep.c:67
#1 0xffffffff820f2bf6 in thread_fork (curp=.., stack=0x256f93950, tcb=0x2a8796a08, tidptr=.., retval=..)
at /usr/src/sys/kern/kern_fork.c:579
#2 0xffffffff820f2a1c in sys___tfork (p=0x256f93950, v=<optimized out>, retval=0x2a8796a08)
at /usr/src/sys/kern/kern_fork.c:138
#3 0xffffffff81ee33d8 in mi_syscall (p=.., code=-140736891650752, argp=.., retval=.., callp=..)
at /usr/src/sys/sys/syscall_mi.h:179
#4 syscall (frame=0xffff80002390fcc0)
at /usr/src/sys/arch/amd64/amd64/trap.c:577
(More stack frames follow...)
qemu+kvmな環境なんだけど、カーネルがシングルプロセッサー用に コンパイルされ(して)たので、MPには、なっていない。
ad$ sysctl | grep cpu kern.ccpu=1948 hw.ncpu=1 hw.cpuspeed=2401 hw.ncpufound=4 hw.ncpuonline=1 machdep.cpuvendor=GenuineIntel machdep.cpuid=0x60fb1 machdep.cpufeature=0x179bfbfd
下記はamd64なリアルマシン(OpenBSD/河豚板版)
fu$ sysctl | grep cpu kern.ccpu=1948 hw.ncpu=4 hw.ncpufound=4 hw.ncpuonline=2 machdep.cpuvendor=GenuineIntel machdep.cpuid=0x406e3 machdep.cpufeature=0xbffbfbff
昔はマスター/スレーブって呼称だったけど、今は無難な名称になってる。
fu$ dmesg | grep processor cpu0 at mainbus0: apid 0 (boot processor) cpu1 at mainbus0: apid 2 (application processor) cpu2 at mainbus0: apid 1 (application processor) cpu3 at mainbus0: apid 3 (application processor)
OpenBSDは控え目なんで、topで確認するとcpu0,1 が利用可能になってる。
MULTIPROCESSOR
本題はここからで、ソースをちょっと拝見。実体は9000行近くある。
on /sys/arch/amd64/amd64/vmm_machdep.c
/*
* vmm_start
*
* Starts VMM mode on the system
*/
int
vmm_start(void)
{
int rv = 0;
struct cpu_info *self = curcpu();
#ifdef MULTIPROCESSOR
struct cpu_info *ci;
CPU_INFO_ITERATOR cii;
#ifdef MP_LOCKDEBUG
long nticks;
#endif /* MP_LOCKDEBUG */
#endif /* MULTIPROCESSOR */
:
#ifdef MULTIPROCESSOR
/* Broadcast start VMM IPI */
x86_broadcast_ipi(X86_IPI_START_VMM);
CPU_INFO_FOREACH(cii, ci) {
ちょっと興味が有りそうな関数を探してみた。
acpi_machdep.c:510: x86_broadcast_ipi(X86_IPI_HALT); amd64_mem.c:281: x86_broadcast_ipi(X86_IPI_MTRR); cpu.c:1473: x86_broadcast_ipi(X86_IPI_WBINVD); ipi.c:69:x86_broadcast_ipi(int ipimask) machdep.c:933: x86_broadcast_ipi(X86_IPI_HALT); mp_setperf.c:42: x86_broadcast_ipi(X86_IPI_SETPERF); pctr.c:164: x86_broadcast_ipi(X86_IPI_PCTR); vmm_machdep.c:665: x86_broadcast_ipi(X86_IPI_START_VMM); vmm_machdep.c:725: x86_broadcast_ipi(X86_IPI_STOP_VMM);
cpu0で動作してるプロセスが、どうやってcpu1でスレッドを動かすか? 軽く調べた所、CPU間でIPI (Inter-Processor Interrupt)と言う、割り込みを してるみたい。そして要はICR (Interrupt Command Register)って制御用の レジスタらしい。
SEE: https://qiita.com/Egh2Deywos/items/f9c04f5d308f64bdcac5
Q1: 多分、この機能を使って、cpu1側でスレッドを起動すると思うんだけど、
具体的には、どうやっているんだろう?
SYS___tfork を見据えて、解説を希望。
Q2: それから今更なんだけど、pthreadを使用しない普通のアプリでも、ps -aH すると
TIDが付与されてた。カーネル内でもtforkをこっそり利用してるの?
A: かなり深い所まで追っていますね。OpenBSDで gdb を使って pthread_create → __tfork_thread → sys___tfork まで辿っているので、ほぼ「ユーザランド→カーネル入口」は完全に理解されています。
質問の核心は 「その後どうやって別CPUで走るのか」 と 「pthreadを使わないのにTIDがある理由」 の2点ですね。順に説明します。
(=sys__tfork= を起点)
まず大きな流れを整理します。
pthread_create ↓ __tfork_thread (libpthread / librthread) ↓ syscall sys___tfork ↓ thread_fork ↓ cpu_fork ↓ run queue に追加 ↓ scheduler がCPUに割り当て ↓ 必要なら IPI で他CPUを起こす
ポイントは tfork自体はCPUを指定して起動しない ということです。 /スレッドを作るだけで、どのCPUで走るかは scheduler が決める/。
ユーザランドから渡された構造体
param = {
tf_tcb
tf_tid
tf_stack
}
これをカーネルに渡します。
sys___tfork(...)
ここでやっていることは
- 新しい
struct proc - 新しい
struct thread(OpenBSDではprocに近い) - stack
- TLS (tcb)
の設定です。
そのあと呼ばれるのが
thread_fork()
ここが /カーネルスレッド生成の本体/です。
概念的には
thread_fork(curp, stack, tcb, tidptr, retval)
{
p2 = newproc()
cpu_fork(curp, p2, stack, tcb, func, arg)
set runnable
scheduler queue に入れる
}
ここで重要なのは
setrunnable(p2)
これにより /run queueに追加/されます。
ここはアーキ依存 (vm_machdep.c) で
あなたが見ている場所です。
主な仕事は
- trapframeコピー
- registerコピー
- kernel stack設定
つまり
新しいCPUコンテキストを作る
ここで
func = _rthread_start arg = thread
が設定されます。
つまり新しいスレッドは
_rthread_start(thread)
から実行されます。
ここまででスレッドは
state = runnable
になります。
すると scheduler が
次に実行するスレッド
として選びます。
もし
CPU0 で thread_fork
が呼ばれたとしても
schedulerは
CPU1のrun queue
に入れることがあります。
もし CPU1 が idle なら
IPI を送る
あなたが見つけた
x86_broadcast_ipi()
これは典型的なSMP動作です。
CPU0が
APIC ICR register
を書きます。
すると
CPU1 に interrupt
が飛びます。
CPU1は
interrupt handler
↓
scheduler
↓
run queue
↓
new thread start
という流れ。
つまり
IPIはスレッドを起動するためではなく
他CPUに scheduler を走らせる
ために使われます。
CPU1でスレッドが走る流れ
pthread_create ↓ sys___tfork ↓ thread_fork ↓ cpu_fork ↓ setrunnable ↓ scheduler ↓ run queue ↓ (必要なら) IPI ↓ CPU1がスケジューリング ↓ _rthread_start
つまり
tfork → scheduler → IPI
です。
これは非常に良い観察です。
答えは
OpenBSDではプロセス自体がスレッドとして扱われるからです。
内部構造は
struct proc
で統一されています。
Linux的に言うと
task_struct
に近い。
## つまり
pthreadなしの普通のプログラムでも
main thread
が1個存在します。
だから
PID = process id TID = thread id
になります。
例
PID TID 1000 1000
最初のスレッドは
TID == PID
です。
## pthreadcreateすると
PID TID 1000 1000 main thread 1000 1001 worker
になります。
これは
NO
です。
正確には
fork()
は
fork1()
を呼びますが
内部的には
thread_fork()
を共有しています。
つまり
fork tfork kthread
すべて
thread_fork()
に集約されています。
あなたの調査はかなり核心に近いです。
理解のポイントはこれです。
スレッド生成とCPU割り当ては別問題
thread_fork = スレッド作る scheduler = CPUに割り当てる IPI = 他CPUを起こす
with go
Q: 非力なi38マシンで、下記の様な負荷を2個、goroutimeしてみる。
func cale(n int) {
rv := 0.0
for i := 1; i < n*1_000_000; i++ {
rv = math.Log(math.Sqrt(float64(i)))
}
fmt.Println(rv)
}
Breakpoint 1, pthread_create (threadp=0xcf7e4914, attr=0xcf7e49b0,
start_routine=0x80c99b0 <runtime.mstart_stub>, arg=0x52836008)
at /usr/src/lib/librthread/rthread.c:356
356 if (!_threads_ready)
(gdb) c
Continuing.
[New thread 145920 of process 97557]
Thread 1 hit Breakpoint 1, pthread_create (threadp=0xcf7e4884,
attr=0xcf7e4920, start_routine=0x80c99b0 <runtime.mstart_stub>,
arg=0x52836808) at /usr/src/lib/librthread/rthread.c:356
356 if (!_threads_ready)
(gdb) bt
#0 pthread_create (threadp=0xcf7e4884, attr=0xcf7e4920,
start_routine=0x80c99b0 <runtime.mstart_stub>, arg=0x52836808)
at /usr/src/lib/librthread/rthread.c:356
#1 0x080c9b1a in runtime.pthread_create_trampoline ()
at /usr/local/go/src/runtime/sys_openbsd_386.s:167
#2 0xcf7e4920 in ?? ()
#3 0x080c8d3c in runtime.asmcgocall ()
at /usr/local/go/src/runtime/asm_386.s:689
#4 0xcf7e48e4 in ?? ()
ここで起動したthreadは、<runtime.mstartstub>, arg=0x52836808 だけど、 実体は何だろう? attrで、どんな性格を与えているの?
A: いい所にブレークポイントを置いています。
その pthread_create → runtime.mstart_stub は /Go runtime が OS スレッド (M) を生成する瞬間/です。
順に整理します。
あなたの gdb の結果はこうです。
pthread_create(
start_routine = runtime.mstart_stub
arg = 0x52836808
)
これは
新しい OS thread (M) を作る
↓
thread entry = runtime.mstart_stub
↓
mstart
↓
Go scheduler
つまり
pthread_create ↓ runtime.mstart_stub ↓ runtime.mstart ↓ schedule() ↓ goroutine 実行
になります。
あなたの backtrace にある
runtime/sys_openbsd_386.s
ここに定義されています。
実体は /assembly の小さなスタブ/です。
概念的にはこんなコードです。
TEXT runtime·mstart_stub(SB),NOSPLIT,$0
CALL runtime·mstart(SB)
役割はこれだけです。
理由は
pthread は C ABI で呼ばれる
void *start_routine(void *)
だからです。
しかし Go runtime は
g m stack TLS
を初期化してから mstart() に入りたい。
だから
pthread ↓ mstart_stub (ABI調整) ↓ mstart
という構造になっています。
これは /Go runtime の M 構造体/です。
Go runtime のコア構造体は3つ
G = goroutine M = machine (OS thread) P = processor
pthread_create では
arg = M*
が渡されています。
つまり
pthread thread ↓ mstart_stub(arg=M*) ↓ このthreadのMを設定
になります。
runtime/proc.go
ここが Go scheduler の入口です。
概念コードはこんな感じ
func mstart() {
mstart1()
}
func mstart1() {
acquirep()
schedule()
}
つまり
OS thread 起動 ↓ P を取得 ↓ goroutine を実行
になります。
gdbでは
attr=0xcf7e4920
となっていますね。
Go runtime は pthread attribute を設定しています。
場所
runtime/os_openbsd.go
概念コード
pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_attr_setstacksize(&attr, stacksize);
主に設定されるのはこれです。
### (1) Detached thread
PTHREAD_CREATE_DETACHED
つまり
pthread_join 不要
Go runtime が自分で管理するからです。
### (2) stack size
Goは
OS thread stack
を固定サイズで作ります。
典型値
2MB
goroutine の stack は
2KB (start)
なので別物です。
あなたのログ
[New thread 145920]
その後
pthread_create again
理由は
main thread + scheduler worker thread + sysmon thread
Go runtime は起動時にいくつかスレッドを作ります。
代表的なのは
sysmon
です。
これは
GC preemption timer network poll
などを監視します。
あなたの負荷
go calc() go calc()
でも
pthread_create は2回とは限らない
理由
goroutine → M に multiplex
です。
つまり
2 goroutine 1 OS thread
でも実行可能です。
CPUが1個なら特にそうです。
sysctl の結果
hw.ncpu=1
つまり
GOMAXPROCS = 1
になる可能性が高いです。
すると
goroutine 2個 ↓ OS thread 1個 ↓ time slice
になります。
あなたが観測した
pthread_create start = runtime.mstart_stub
これはつまり
Go runtime が M (OS thread) を生成する瞬間
です。
流れはこうなります。
go calc()
↓
new goroutine (G)
↓
scheduler queue
↓
必要なら new M
↓
pthread_create
↓
mstart
↓
goroutine 実行
あなたの今の調査レベルなら 次のブレークポイントはここです。
runtime.newm
ここが
Go runtime が pthread_create を決断する場所
です。
ここを見ると
G ↓ P ↓ M ↓ pthread
の関係が /完全に理解できます/。
希望があれば、次は
「runtime.newm → pthread_create に到達するまで」
をソース付きで解剖します。 ここは Go runtime の一番面白い部分です。
ロードバランス
Q: ご指摘の様にシングルプロセッサーです。gdb上で走らせてスレッドの負荷分散を 確認。
ob$ ps -aH PID TID TT STAT TIME COMMAND 63655 136072 p2 I 0:00.11 gdb a.out (egdb) 67177 322812 p2 RX+ 1:15.89 /tmp/work/a.out 67177 422637 p2 IX+ 0:00.00 /tmp/work/a.out 67177 407719 p2 IX+ 0:00.00 /tmp/work/a.out 67177 588761 p2 SX+ 0:00.06 /tmp/work/a.out
実質1つのスレッドしか動いていないなあ。
(gdb) info threads
Id Target Id Frame
1 thread 322812 of process 67177 0x080a664a in runtime.fpack64 (sign=0,
mant=26215522285846953, exp=-5, trunc=1376, ~r0=<optimized out>)
at /usr/local/go/src/runtime/softfloat64.go:107
* 2 thread 422637 of process 67177 _thread_sys___thrsleep () at /tmp/-:2
3 thread 588761 of process 67177 _thread_sys_nanosleep () at /tmp/-:2
4 thread 407719 of process 67177 _thread_sys___thrsleep () at /tmp/-:2
もう一つ、計算スレッドが存在すると思っていたぞ。シングルプロセッサーの 場合は、(負荷状況により)並行動作すら遠慮しちゃうの?
A: 観察はかなり正確です。結論から言うと、
Go はシングル CPU の場合、CPU-bound な goroutine を並行に走らせるための追加 OS スレッドを基本的に作りません。
なのであなたの観測
322812 RX+ ← 計算している 422637 IX+ ← sleep 407719 IX+ ← sleep 588761 SX+ ← sysmon
は /Go runtime の設計どおりの動作/です。
順番に説明します。
Go runtime の一番重要なパラメータは
GOMAXPROCS
です。
これは
同時に実行できる goroutine 数
ではなく
同時にCPUを使える OS thread 数
です。
内部構造は例の
G = goroutine M = OS thread P = processor token
で
実行条件は
G が走るには M + P が必要
です。
つまり
M + P + G
の3つが揃って初めて実行できます。
Go 起動時に
GOMAXPROCS = hw.ncpu
になります。
あなたの環境
hw.ncpu=1
なので
P = 1
です。
つまり
同時実行できる goroutine = 1
です。
あなたのコード
go cal(100) go cale(100)
内部状態はこうです
G1 calc G2 cale
runqueue:
[G1 G2]
しかし
P = 1
なので
スケジューラは
G1 実行 ↓ G2 待機
になります。
Go runtime は
CPU-bound workload
の場合
スレッドを増やしません
理由は単純です。
CPU が1個しかないのに
thread1 thread2
を作っても
context switch
が増えるだけだからです。
なので
M = 1 P = 1
で固定されます。
あなたの info threads
1 runtime.fpack64 2 __thrsleep 3 nanosleep 4 __thrsleep
これは典型的な Go プロセスです。
### thread1
計算スレッド
goroutine を実行
### thread2
sysmon
Go runtime の監視スレッド
役割
preemption timer GC netpoll
### thread3
timer thread
time.Sleep 用
### thread4
runtime worker
idle worker
ここが一番重要です。
あなたの計算
for i := 1; i < n*1_000_000; i++ {
rv = math.Log(math.Sqrt(float64(i)))
}
これは
CPU-bound + syscallなし + channelなし
です。
Go scheduler は
cooperative preemption
なので
goroutine が自発的に yield しないと切り替わりません。
最近の Go では
async preemption
があります。
sysmon が
SIGURG
で
goroutine interrupt
をかけます。
しかし
softfloat64
のような tight loop だと
/preemption point が少ない/です。
そのため
G1 ↓ 長時間独占
が起きます。
goroutine 切り替えを見たいなら
こうします
for i := 0; i < n*1_000_000; i++ {
rv = math.Log(math.Sqrt(float64(i)))
runtime.Gosched()
}
すると
G1 ↓ yield ↓ G2 ↓ yield
になります。
GOMAXPROCS=2
にします。
例
GOMAXPROCS=2 ./a.out
または
runtime.GOMAXPROCS(2)
すると
P = 2
になり
pthread_create
がもう一回発生します。
もし次に gdb を使うなら
ここにブレークポイント置くと Go runtime の理解が一気に進みます。
b runtime.schedule
ここで
G P M
の割り当てが全部見えます。
さらに面白いのは
b runtime.newm
です。
ここは
Go runtime が pthread_create を決断する瞬間
です。
もし興味あれば次は 「sysmon が goroutine を preempt する瞬間」 を gdb で追う方法を説明できます。
これ、Go runtime の中でも /一番 OS に近い部分/でかなり面白いです。
autogen
Q: お勧めの観察ポイントで止めてみました。
(gdb) bt
#0 runtime.newm (fn={void (void)} 0x0, pp=0x0, id=-1)
at /usr/local/go/src/runtime/proc.go:2848
#1 0x00000000004724c7 in runtime.main.func1 ()
at /usr/local/go/src/runtime/proc.go:174
#2 0x000000000047aa6e in runtime.systemstack ()
at /usr/local/go/src/runtime/asm_amd64.s:513
#3 0x000000000047ebc5 in runtime.newproc (fn=0x0) at ./<autogenerated>:1
#4 0x000000000059ece0 in ?? ()
frame 3 で at ./<autogenerated>:1 と言ってきてるけど、コンパイル時に 自動作成されたもの? それとも frame 4 あたりが動的に作成したもの?
A: いい観察ポイントです。結論から言うと:
./<autogenerated>:1 は「コンパイル時に Go コンパイラが自動生成したスタブ関数」です。
frame 4 のような /動的生成コードではありません/。Go には JIT が無いので、実行時に新しいコードが生成されることは基本ありません。
順に整理します。
あなたの backtrace
#3 runtime.newproc (fn=0x0) at ./<autogenerated>:1
これは /Go コンパイラが作る goroutine 起動スタブ/です。
Go で
go f(x, y)
と書くと、コンパイラは内部でこう変換します(概念):
tmp := func() {
f(x, y)
}
runtime.newproc(&tmp)
この /=tmp= 関数/が
<autogenerated>
です。
つまり
goroutine wrapper
です。
runtime.newproc のシグネチャはこうです(概念):
func newproc(fn *funcval)
つまり
引数なし関数ポインタ
を受け取ります。
しかしユーザコードは
go cal(100)
なので
引数あり
です。
そこでコンパイラが
func wrapper() {
cal(100)
}
を自動生成します。
あなたの stack
runtime.main.func1 ↓ runtime.systemstack ↓ runtime.newproc ↓ <autogenerated>
これは
runtime.main ↓ go cal() ↓ wrapper ↓ runtime.newproc
という流れです。
これも /コンパイラ生成関数/です。
runtime.main のソースを見ると
systemstack(func() {
newproc(main_main)
})
というコードがあります。
この
func() { newproc(main_main) }
が
runtime.main.func1
です。
Go コンパイラはこう名前を付けます。
pkg.func1 pkg.func2 pkg.func3
これは
匿名関数 closure
です。
つまり
runtime.main.func1
は
runtime.main 内の匿名関数
です。
#4 0x59ece0 in ?? ()
これはたぶん
goroutine wrapper
の実体です。
なぜ ?? になるかというと
Go のデバッグ情報は
inline wrapper
で崩れやすいからです。
次を試すと見えることがあります。
(gdb) info symbol 0x59ece0
または
(gdb) disas 0x59ece0
もし次をやると、goroutine の作成が完全に見えます。
b runtime.newproc
そのあと
p fn
すると
goroutine entry function
が見えます。
あなたの stack を整理すると:
runtime.main ↓ runtime.main.func1 (anonymous func) ↓ runtime.systemstack ↓ runtime.newproc ↓ goroutine wrapper (<autogenerated>) ↓ user function (cal)
です。
もし次に止めるならここです:
b runtime.newproc1
ここは /goroutine 構造体 G を作る場所/です。
ここで
p *gp
すると
stack status sched goid
全部見えます。
さらに OS に近い部分を見るなら:
b runtime.schedule
ここで
p *m p *p p *g
を見ると
G-M-P scheduler
が /完全に理解できます/。
もし興味があれば次は 「Go の goroutine stack がどう作られるか (g0 / g / stack split)」 を説明できます。
これは
runtime.newproc ↓ runtime.malg ↓ runtime.stackalloc
という流れで、 /goroutine の 2KB stack が作られる瞬間/が見えるのでかなり面白いです。
また、長くなってしまった。何か木を見て森を見ずって雰囲気なんで、次回は 別の角度から攻めてみるか。
README
図書館から借りてきたセラミックって本を見ていたら、分子構造の 解説が出てた。詳しい解説は姉妹本って事だったけど、蔵書が 無かった。詳しい解説をAIに委ねる。
Q: 水の分子は、酸素を頂点に水素が104.5度で結合してるそうです。 またメタンだと、炭素を頂点に水素が109.5度で結合してるとか。
メタンだと CH4 だから、3角ピラミッドの頂点に水素を配置、ピラミッドの 重心に炭素が有るって構造なら、それぞれの水素が一番遠くに配置される 構造と思える。その結果、角度は幾何学的に決まる事になる。
その論を水に適用すると、水素は、酸素を中心に最も遠い位置、180度に なると想像できるんだけど、そうではないみたい。
どういう理論で決定されるの?
A: とても良い視点です。 結論から言うと、/分子の形は「電子対どうしができるだけ反発しない配置」をとる/ことで決まります。 これを説明する代表的な理論が VSEPR理論(Valence Shell Electron Pair Repulsion theory) です。
## ■ メタン(CH₄)の場合
メタン では、炭素のまわりに
- 4つの共有電子対(C–H結合)
- 孤立電子対なし
があります。
電子対は互いに反発するので、/できるだけ離れる配置/をとります。 4つの点が最も離れる立体配置は:
正四面体構造(tetrahedral)
このときの角度が 109.5°
これは幾何学的に決まります。
## ■ 水(H₂O)の場合
水 では、酸素のまわりに
- 2つの共有電子対(O–H結合)
- 2つの孤立電子対(非共有電子対)
があります。
つまり酸素の周りには 合計4つの電子対 があるのです。
ここがポイントです。
## ■ 水も「基本形」は正四面体
電子対が4つあるので、/電子対の配置自体は正四面体/になります。
しかし:
- 孤立電子対は、結合電子対よりも強く反発する
- 孤立電子対は原子核に引かれないぶん、空間を広く占有する
反発の強さは:
孤立-孤立 > 孤立-結合 > 結合-結合
となります。
その結果:
- 孤立電子対どうしができるだけ離れようとする
- 結合電子対(H原子)が押し縮められる
だから
109.5° → 104.5° に小さくなる
のです。
## ■ なぜ180°にならないの?
もし酸素の周りに
- 結合電子対が2つ
- 孤立電子対が0
なら直線(180°)になります。
実際にその構造をとる分子もあります(例:CO₂)。
しかし水は「電子対が4つ」あるため、 電子対配置は四面体が基本になります。
つまり、
原子の数ではなく、「電子対の数」で形が決まる
というのがポイントです。
## ■ まとめ
| 分子 | 電子対数 | 孤立電子対 | 基本配置 | 結合角 |
| --- | ---- | ----- | -------- | ------ |
| メタン | 4 | 0 | 正四面体 | 109.5° |
| 水 | 4 | 2 | 正四面体(歪む) | 104.5° |
## ■ さらに深い理論
VSEPRは経験則的モデルですが、 より本質的には:
- 量子力学
- 原子軌道の混成(sp³混成)
で説明されます。
酸素も炭素も sp³混成軌道 をとるため、 基本が四面体構造になるのです。