資料館/ネトゲ製作/005

Last-modified: 2009-01-10 (土) 09:36:41

データの送り方について学んだりする

今回は、「データの送受信」というネット対戦の根底とも言うべき部分に光を当てていく。このページの内容を全て理解したならばアタマもすっきり、ネットワークプログラミングに勤しむ事ができるだろう。


少なくとも、俺が知っている範囲では・・・

Windowsが通信する仕組み

Windowsがどのように通信しているか、特に受信について大まかに見ておく。今まで受信バッファ受信バッファといわれてきたが、そいつはいったい何なのか。

受信を理解する

PCBNet2を用いてTCP通信を行うとき、データの受信側は、tcprecvなどを用いてデータを受信する。この受信するデータは、受信バッファと呼ばれるところに溜まっているデータだ。データがネットワークを通じて送られてくると、まず受信バッファへ溜まる。この仕組みのおかげで、少々受信側でtcprecvが遅れたとしても、データが破棄されず受信できる。
recv001.png

溜まっているデータを、各種受信命令を用いて的確な量汲み上げる感じだ。受信バッファに20byte溜まっていたとして、tcprecvで4byte受信したとすると、受信バッファに16byteが残っている、という計算になる。
このデータはソケットごとに、到着したデータがどんどん後ろに並んでいく。

	tcpsend A,0,4,socket
	tcpsend B,0,12,socket
	tcpsend C,0,16,socket

こんな風に相手に送信したとすると、相手の受信バッファの中身は全部で4+12+16の32byteになり、出口に近いほうに
recv002.png
こんな風に並んでいる。ただし、tcpsendを実行したら瞬時に相手に全て届くわけではなく、回線速度によって徐々に溜まっていく感じだろう。まあ今のご時勢なら32byteくらいほぼ一瞬だと思うが。ここでさらにDを送信すると、Cのデータの上にDが積まれていく。


さて、ここで以下の受信を行ってみる。

	tcprecv recivedata,0,8,socket

このとき、recivedataには、データAと、Bの一部、二つが一緒に受信される。この処理は、先頭から8byte受信する処理なのでデータの区切りなどは自分でどうにか処理しないと、このように混ざったデータになってしまい、思うような処理が出来ない場合があるので注意しよう。
しかし逆に、データの並びさえわかっていれば、一度に受信したバイナリデータをunpackで取り出すような処理を行う事ができる。

	tcprecv recivedata,0,32,socket
	unpack recivedata,"d4d12d16",A,B,C

送信側はpackしたデータを送信したわけではないが、受信側は一度にデータを受信したので、unpackによりデータを分解する事ができる。

バッファオーバーランの危険性を学ぶ

再三、tcprecvにつかう変数は、十分なサイズを確保するように、と書いてきた。何故か。

  1. データがめちゃくちゃになってデバッグどころじゃない←まじ泣きたくなる
  2. 下手するとWindowsの挙動がおかしくなる←シャレにならない
  3. プログラムの領域に意図的にコードを流されでもしたら・・・←ゲイツの得意技

こんな理由があるからである。
2番のやばさはもう説明しようがないので省くが、1番は1番でやばい。企画存続の危機だ。ネットワークを実装した途端に、いままで正常に動いていたゲーム部分が動かなくなるのだ。どこにエラーがあるのかわからず、血を吐くこと請け合い。
HSPは本来、バッファオーバーランが発生しそうになったら自動的に要領の再確保を行うため、お目にかかることは少ない。たまにエラーメッセージでみるくらいかしら?従ってバッファオーバーランの解説は本来なら他のサイトにでも投げておくべきモノであるが、実際にHSP+PCBnet2という組み合わせにおいて、一年位前に1番の状況が発生し、4日くらい泣いたので、あえてこの項目においてバッファオーバーランの解説をやる。

	dim box,5

	box.0=1
	box.1=0

こんな感じの宣言をしたとき、メモリのなかには4byteの領域が5個連続で確保された。この配列の0番に、6byteのデータを格納しようとする。
over01.jpg
HSPの標準命令でやろうとすると、エラーを吐いて止まるか、自動的にサイズを拡張してバッファオーバーランを防ぐが、tcprecvを用いて6byteのデータを受信したと考えるとわかりやすい。
over02.jpg
見事なバッファオーバーランの出来上がりだ。データ1ブロックは4byteなのに書き込もうとしたデータが6byteなので、後ろに控えているbox.1の値を破壊してしまった。box.1の内容を出力すると、0ではない値が格納されていることだろう。このようにHSP+PCBNet2の状況においては簡単にバッファオーバーランが起こせるので、十分注意されたし。

数値データを効率よく送信する

数値のサイズに適切な値を指定する

パソコン上のデータは全て2進数によって管理されているという事は、パソコンに明るくない人々でも言葉では知っているだろう。
2進数一桁を1ビットといい、0と1を表現できる。これを8個並べたものを1バイトという。別に算数の授業をやってるわけではないが、byteを8倍するとbitになり逆にbitを1/8倍するとbyteになる、という事ぐらいは、今のご時勢頭に叩き込んでおくべきだ。
さらに2進数について詳しく知りたい場合は、頭痛覚悟でウィキペディアにでも行こう。*1


ところでパソコンのCPUには、xxビット、という風に性能が書かれていることがある。Windows XPも、64bit版なるものがあるじゃないか。このビットは、CPUが扱える数字の最大数だと思っておいて差し支えない。16bitのOSと32bitのOSでは、一度で扱える数字がかなり違ってくる。


さて、HSPで変数を一個宣言すると、それは4byteのデータとなるのは、今までやってきた送受信の中で鵜呑みにしつつやってきたが、ここでその理由が判明する。
HSPが動作している環境は、4*8、つまり32bit環境なのである。*2大抵のパソコンは、32bitを基準にして計算しなさい、という風に誰かが決めたようで、そうなっている。一回の計算で利用できる値の最大値が32bitだから、整数を一個メモリに格納しようとすると、32bit、つまり4byte消費してしまう、という事らしい。


32bitに格納できる値は、符号を除くと0-4294967295までだ。
ちょっとまってくれ。殆どの場合において、こんな馬鹿でかい数字は使わない。例えば、数字の15を送信したいとする。今までやってきた知識で普通に送信しようとすると、このデータは4byteとなる。
ところが、15というのは、8bitで表す事ができる。つまり、1byteで送信できるのだ。
PCBNet2のpack関数は、整数を1byte,2byte,の符号付、符号無し、4byteの系6パターンのなかから選んでpackすることが出来る。変数を二個送る際に

	a=2:b=500
	pack data,"ii",a,b

とこう書いても送信する事ができるが

	a=2:b=500
	pack data,"cs",a,b;8bit整数,16bit整数

とこのようにパックすると、8byteだったデータが3byteになる。中身は変わっていない。
このとき、変数dataの中を2進数で表現すると多分こんな感じ。途中のスペースは便宜上の区切りで、本当のデータには無い。

両方32bit
01000000000000000000000000000000 00101111100000000000000000000000
8bit+16bit
01000000 0010111111000000

空欄が0で埋まっているのだが、その分もキッチリ送信するデータに含まれるわけだ。これを、適切に指定する事で削減し、送信するデータの減少に努める。注意点は、その値が取りうる値の範囲をきちんと格納できる長さでやること。
たとえば、1byteの変数は0~255、マイナスを含めるならその約半分の範囲しか値を格納できない。ここに、最大値が1000の変数を格納しようとしてもうまくいかない。どうにか内部でうまく調節して、なるべく小さなデータをやり取りできるようにすべきだ。

フラグの送信について語る

ゲームの中で、0と1のみで管理されているフラグを送信したくなったとする。チャットシステムの例で言えば有効フラグを100個送信する、なんて時だ。
前の項目を読んだ良い子なら「0か1しか値をとらない数字に4byteも使ってらんねえ。1byte指定だお!」と考えるかもしれない。それはそれでいい。


ところが、本当は1byteどころの話ではない。フラグは0or1、つまり1bitで管理されているのだ。1byte=8bitなので、本当は1byteの変数に、フラグを8個格納できる。順序を送受信側で決めておいて、フラグを011101101....という風に並べて、これを数値化して格納すればフラグはコンパクトにまとまる。
拡張命令のstrtointを使うと、サクッと変換できる。どうにかべき乗を計算すれば標準命令でも可能だろう。一つの数値型変数にフラグを32個まで格納可能だ。

実数値のpackとunpack

向学心の強いチルドレンは、実数値のpackをトライした事があるかもしれない。結果できなかったはずだ。何故出来ないかはここでは述べないが、どうにか方法は無いものか。


簡単な解決方法がある。バイナリデータとしてpack、unpackすればいい。以上。

でかいデータの受信

前回ちょっと触れたような気もするが、データは瞬時に相手の受信バッファに届くわけではない。ADSL、光回線などご家庭の事情によって異なるが、送受信双方の回線の速度に依存する。厳密に言えば、道程の回線全ての影響を受けるため、実際にデータを送ってみるまで、相手のところにどのくらいの時間で届くか、ということを判断するのは難しいだろう。
我が家のうんこDSLは、上り150KB/s程度が限界だ。1MBのデータを送信しようと思った場合、送りきるまでに既に6秒以上かかる計算だ。受信側がいくらマッハ回線でも、通信相手によっては十分な速度で通信できない可能性があることを視野に入れよう。


10MBのデータを受信する例で説明する。
データは細切れに送信され、徐々に相手の受信バッファにたまっていく。まず受信側は、二通りの状況に分けられる。

  • 予め、送られてくるデータサイズが寸分の狂いなく判明している
  • データのサイズが良くわからない

次に、データの受信方法も二通りに分かれる

  • データが受信バッファに溜まるのを待って受信→一発受信
  • 受信バッファに届いたそばから格納していく→逐次受信

この名前はオラが適当に今つけたものなので正式名称ではない。
前者はtcpcountなど、後者はtcprecvを無限ループで呼び続け、データが終わったらループを抜けて終了するわけだ。受信データのサイズがわからない場合についてだが、どうにかしてわかっていたほうがやりやすい。受信バッファにデータがドカドカ乗っかってくると、どこが区切りかわからなくなってしまうからだ。

データの区切りを意識する

ほいで、データを以下のように構成すると、データサイズが定まっていない場合にわかりやすくなる。

  • 受信バッファの先頭4byteは、データのサイズ
  • 次の1byteは、データの単位(バイト、キロバイトなど)をあらわすコード

こういう風に、意味を持った区切り符号を、データの間に挟んでやる。この区切り符号のサイズは予め決めておこう。こうすれば受信側は

  1. 受信バッファが5byte以上になったら、5byte受信する
  2. 区切り符号を解析し、データサイズを求める
  3. データサイズに従って、一発受信なり逐次受信なりする

という段取りを踏める。次のデータがどんどん送られてくる場合、3が終わった段階の次の5byteがまた区切り符号、という風にしておけば、がんがん受信できることだろう。多分ね。
区切り符号を、Fとした場合の受信バッファのイメージ

FデーターーーーーFデェタFでーたああああああああFでたFデーt....



こんなことをしなくても、データが一定時間途切れたら終了、とする手もある。あるにはある。あるが、ほんとにそれでいいのか。
データが一定時間送られてこなかったら終了、とあるが、ひょっとしたら相手側がラグってるだけかもしれない。エラーで強制終了してしまったのかもしれない。という風に様々な状況が考えられる。さらに、途切れた、ということを判断するために余計な時間がかかる。

一発受信と逐次受信の比較

  • 一発受信の特徴
    • 溜まるまで待つだけなので実装が単純
    • データが受信し終わるまで処理に移れない
  • 遂次受信
    • オフセット値を指定するなど、ちょいややこしい
    • 受信したそばからデータを処理していくことが出来る

一発受信の例

一発受信は、溜まるまで待つだけなので実装が単純だ。10MBのデータを受信する例

	repeat
		tcpcount itmp1,socket
		if itmp1>=10*1024*1024:break;10MB溜まったら抜ける
		await 10
	loop

	tcprecv data,0,10*1024*1024,socket;10MB受信

遂次受信の例

通常、データは全て受信しないと意味を成さないものが多いが、受け取ったデータをガンガン出力、編集などする場合に利用する可能性はあるだろう。

	datasize=10*1024*1024;受信したいデータのサイズ
	nowdata=0;既に受信したデータのサイズ
	recivesize=100*1024;一度に受信する最大量
	repeat
		tcprecv data,nowdata,recivesize,socket
		nowdata+=stat
		if nowdata=datasize:break
		if (datasize-nowdata)<recivesize:{
			recivesize=datasize-nowdata
		}
		await 10
	loop

nowdataにいままで受信してきたデータのサイズを指定し、tcprecvのオフセット値に指定する事で実現する。
8行目のif文だが、残りのデータが少なくなってきたら、次のデータを受信しないようにそれにあわせて受信単位の変更を行う。


*1 二進法
*2 実際には、多分HSPが32bitを意図的に宣言しているのではないかと思うが良く判らん。オープンHSPにでも行け。