서론

이 글에서는 이더리움 코어의 중요 축인 P2P에 관해서 분석해 보도록 하는데, 관련 소스는 파이썬 구현체인 pydevp2p 를 대상으로 한다. go 구현체도 있는데 왜 파이썬이냐?  

첫째. pydevp2p 는 p2p 에 관해서 독립적인 모듈이다. 즉 이더리움 뿐만 아니라, 분산p2p네트워킹을 하려는 많은 곳에서 재사용 될 수 있다. devp2p와 비슷한 libp2p 는 IPFS에 사용되었으며, 이더리움 SWARM 프로토콜을 이해하는데도 필수적이다.
둘째. 읽기 쉽다. 파이썬은 최고로 가독성이 좋은 언어이다. 개인적으로 모든 개발자들은 커뮤니케이션을 위해 파이썬을 읽을 줄 알아야한다고 생각한다. 

사전지식

소스를 온전히 이해하기 위한 많은 사전 지식이 있는데 나열해 보면 아래와 같다.

1. 파이썬 언어 (기본 문법 + 리플렉션 등 고급문법)
2. gevent 라이브러리를 이용한 소켓통신 및 비동기 I/O , Multiplexing  개념
3. ECC 기술들(ECDSA,ECDH),대칭키,공개키,암호화해싱,서명 같은 암호화 기본 
4. Kademlia DHT 
5. RLP 인코딩/디코딩
6. 기타 (NAT,홀펀칭,UPNP 등 개념)

pydevp2p 소개

pydevp2p 홈페이지에 있는 내용은 대략 이렇다.

"pydevp2p is the Python implementation of the RLPx network layer. RLPx provides a general-purpose transport and interface for applications to communicate via a p2p network. The first version is geared towards building a robust transport, well-formed network, and software interface in order to provide infrastructure which meets the requirements of distributed or decentralized applications such as Ethereum. Encryption is employed to provide better privacy and integrity than would be provided by a cleartext implementation."

pydevp2p 가 제공하는 기능들은 아래와 같은데 소스에서 어떻게 구현 되어 있는지  몇 가지를 살펴 볼 것이다. 사실 개발자라면 이런 추상(?)틱한 소개글 보다는 소스를 직접보는게 훨씬 이해하기 쉬울 것이다. 

Features

  • Node Discovery and Network Formation
  • Peer Preference Strategies
  • Peer Reputation
  • Multiple protocols
  • Encrypted handshake
  • Encrypted transport
  • Dynamically framed transport
  • Fair queuing

Security Overview

  • nodes have access to a uniform network topology
  • peers can uniformly connect to network
  • network robustness >= kademlia
  • protocols sharing a connection are provided uniform bandwidth
  • authenticated connectivity
  • authenticated discovery protocol
  • encrypted transport (TCP now; UDP in future)
  • robust node discovery


소스 분석 시작 

1. app (app.py)

pydevp2p 는 아래와 같은 2가지 서비스로 이루어져 있다.

프로그램의 시작점인 app.py 를 보면 아래와 같이 두개의 서비스를 등록&시작하는 코드를 볼 수 있다. 

# register services
NodeDiscovery.register_with_app(app)
PeerManager.register_with_app(app)

각각의 서비스를 등록하고 

def start(self):
for service in self.services.values():
service.start()

각각의 서비스를 시작한다. 이들 서비스는 BaseService 라는 공통부모를 가지고 있는데, Greenlet 이기도 하다.

class BaseService(Greenlet):

Greenlet 은 gevent 라이브러리에서 지원하는 경량 쓰레드라고 생각하면 된다. (golang의 코루틴과 비슷한 면이 있다) 즉 서비스들은 별개의 쓰레드로 작동한다고 보면 된다. 

2. NodeDiscovery 

노드디스커버리는 처음 어플리케이션이 실행 될 때, 주변의 연결될 노드들의 정보를 찾고, 다른 노드들을 위해 (다른노드들의 노드검색을 위해) 자신과 가까운 노드들의 정보를 저장해 놓는 곳에 대한 알고리즘을 다룬다. kademlia DHT 를 알고 있다고 가정한다. 모르면 예전에 썼던 글을 참고하라. [Ethereum] Node Discovery with Kademlia 

클래스 구성은 대략 위와 같으며 대락 설명하면 

1. NodeDiscovery 는 pydevp2p 에서 제공하는 서비스의 일종이다. 
2. NodeDiscovery 서비스는 노드를 찾기위한 DiscoveryProtocol 를 사용하는데, 구체적으로 KademliaProtocol 를 사용한다.
3. NodeDiscovery 서비스는 DiscoveryProtocol 를 통해 발생하는 명령들을 최종적으로 UDP 를 이용하여 전송하고 받는다. 
4. DiscoveryProtocol 는 하위 프로토콜이 무엇이든지 간에 (여기서는 Kademlia 지만) RLP 를 통해 전송/수신 메세지에 대해 인코딩/디코딩하고, 암호화 한다. 
5. 모든 노드디스커버리 프로토콜이 상속받아야하는 (하지만 여기서는 kademlia 에 종속된~ 디자인 실패?) WireInterface kademlia 에서 사용되는 주요 프로토콜을 선언해두고 있다. (send_ping,send_pong,send_fine_node,send_neighbours) 
6. KademliaProtocol 은 kademlia 노드디스커버리를 구현하며 RoutingTable 를 관리한다.  kademlia DHT 의 모든 구현이 되있는것은 아니고, 토렌트 같은 데이터를 찾고 저장하는 부분은 제외되있다. 
7. RoutingTable 은 내가 가까운 노드들을 많이 담고있고, 먼 노드들은 적게 담고 있는 테이블이다.
8. KBucket 은 RoutingTable 의 한개의 로우에 해당하며, 개수는 지정하기 나름이다. (보통 16개)
9. Node는 하나의 peer 를 나타내며, xor 연산을 하기 위한 id 를 가지고 있다. 이 노드를 상속받아서 더 구체적인 정보(ip,port,reputation등)를 가지고 있는 노드도 있다.

주요 소스를 살펴보자. 녹색 글짜로 주석을 달아 놓았으며 빠른 이해를 위해, 예외등을 삭제했다. 

2-1. NodeDiscovery 의 start (discovery.py 에 존재)


class NodeDiscovery(BaseService, DiscoveryProtocolTransport):
// 노드디스커버리를 위한 port 30303 을 설정한다.
default_config = dict(
discovery=dict(
listen_port=30303,
listen_host='0.0.0.0',
),
node=dict(privkey_hex=''))
// 생성자에서 DiscoveryProtocol 객체를 생성한다.
def __init__(self, app):
BaseService.__init__(self, app)
self.protocol = DiscoveryProtocol(app=self.app, transport=self)
     // 리모트 노드에 message 를 전송한다. 전송시에는 UDP server 를 이용한다.

def send(self, address, message):
self.server.sendto(message, (address.ip, address.udp_port))
// 리모트 노드에서 받은 message 를 kademlia 프로토콜 객체로 건네 준다. // 즉 비지니스 로직은 kademlia 프로토콜 객체에 있고, 이 NodeDiscovery 는 전달자,조정자의 역할이다.

def receive(self, address, message):
self.protocol.receive(address, message)

// NodeDiscovery 를 시작한다. // 1. upnp 설정을 하여 nat에서도 사용되게 한다. // 2 DatagrameServer 를 시작한다 // 3. kademlia protocol 을 이용하여 설정파일에 기록되있는 노드를 통해 bootstrap한다.

def start(self):
ip = self.app.config['discovery']['listen_host']
port = self.app.config['discovery']['listen_port']
self.nat_upnp = add_portmap(port, 'UDP', 'Ethereum DEVP2P Discovery')
self.server = DatagramServer((ip, port), handle=self._handle_packet)
self.server.start()
super(NodeDiscovery, self).start()
// bootstrap 시작한다.
nodes = [Node.from_uri(x) for x in self.app.config['discovery']['bootstrap_nodes']]
if nodes:
self.protocol.kademlia.bootstrap(nodes)


2-2. KademliaProtocol 의 bootstrap (kedemlia.py 에 존재)

// 가장 먼저 부트스트랩 노드를 일단 라우팅 테이블에 추가한다. (라우팅 테이블은 계속 업데이트 된다) // 그 후에 find_node 를 통해 자신과 가까운 노드들의 정보를 물어 본다. // find_node 의 첫번째 인자는 자신이고, 두번째는 부트스트랩 노드이다. // 즉 부트스트랩 노드에게 자신의 노드와 가까운 노드를 찾아 달라는 것이다.

def bootstrap(self, nodes):
for node in nodes:
if node == self.this_node:
continue
self.routing.add_node(node)
self.find_node(self.this_node.id, via_node=node)

이어서 find_node 를 살펴보자.

// via_node 에게 tergetid 와 가까운 노드를 찾아 달라는 것이다. 만약 특정 via_node가 없으면 // 자신의 라우팅테이블에서 검색해서 가까운 노드들에게 모두 find_node를 시키며, // via_node가 있으면 , 리모트 노드에게 물어본다. // 여기서 find_requests 는 일정시간동안 응답이 없으면 무효화하기 위함이다.

def find_node(self, targetid, via_node=None):
self._find_requests[targetid] = time.time() + k_request_timeout
if via_node:
self.wire.send_find_node(via_node, targetid)
else:
self._query_neighbours(targetid)

실제 메세지를 보내는 send_find_node 는 암호화 및  rlp 인코딩 후 UDP 를 사용할 것이다. 확인해보자.

// 해당 메세지를 packing 하여 UDP 로 보낸다. 여기서 .packing이 바로 rlp 인코딩이다. def send_find_node(self, node, target_node_id):
message = self.pack(self.cmd_id_map['find_node'], [target_node_id])
self.send(node, message)

rlp 인코딩 및 무결성 체크 데이터를 통해 최종 메세지 생성 (여기서는 비밀키를 통해 암호화는 하지 않는다. 나중에 peermanager 서비스에서는 ECDH 키교환에 의한 비밀키를 생성하여 암호화하는 부분을 볼 수 있을 것이다) 

//find_node 에 해당하는 cmd_id 와 실제 보내질 메세지를 인자로 받는다. // rlp.encode 를 통해 payload 를 인코딩한다. // 인코딩된 데이터를 sha3 로 서명하고 무결성을 위하여 MDC를 만든다. // 최종적으로 MDC + 서명 + CMD_ID + 실제 데이터 가 보내어진다.

def pack(self, cmd_id, payload):
cmd_id = str_to_bytes(self.encoders['cmd_id'](cmd_id))
expiration = self.encoders['expiration'](int(time.time() + self.expiration))
encoded_data = rlp.encode(payload + [expiration])
signed_data = crypto.sha3(cmd_id + encoded_data)
signature = crypto.sign(signed_data, self.privkey)
mdc = crypto.sha3(signature + cmd_id + encoded_data)
return mdc + signature + cmd_id + encoded_data

 query_neighbours 를 살펴보자. 자신의 라우팅 테이블에서 주어진 node_id와 그나마 가장 가까운 노드들에게, 자신과 진짜로 가까운 노드들이 또 있는지 찾아달라고 부탁한다.

// 라우팅테이블의 neighbours 메소드를 호출하여 가까운 것들을 찾아서 모두에게 find_node를 부탁한다. def _query_neighbours(self, targetid):
for n in self.routing.neighbours(targetid)[:k_find_concurrency]:
self.wire.send_find_node(n, targetid)

마지막으로 neighbours 는 이렇다.

// 일단 인자로 들어온 node와 가장가까운 버킷에서 가장 가까운 노드부터 채워넣는다. // k_bucket_size의 2배만큼~ 논문에는 k_bucket_size 는 16으로 되어있으며, 이더리움에서도 16을 쓴다. def neighbours(self, node, k=k_bucket_size):
nodes = []
for bucket in self.buckets_by_id_distance(node):
for n in bucket.nodes_by_id_distance(node):
if n is not node:
nodes.append(n)
if len(nodes) == k * 2:
break
return sorted(nodes, key=operator.methodcaller('id_distance', node))[:k]

Nodediscovery는 나름 단순하므로 나머지 부분은 각자 소스를 보면 되리라 본다.

3. PeerManager

PeerManager는 프로토콜 별로 framming 하는 부분이 소켓 프로그래밍에 있어서 꽤 교육적이고, 리플렉션을 통해 다중 프로토콜을 유연하게 확장 하려는 모습이 돋보여서 나름 읽어볼만하다. 개발자에게 오픈소스는 무협지 같은 소설 아니던가. ~


클래스 구성은 대략 위와 같으며 설명하자면 

1. PeerManager도 NodeManager와 마찬가지로 pydevp2p 에서 제공하는 서비스의 일종이다. 
2. PeerManager 는 StreamServer 를 통해 발생하는 명령들을 최종적으로 TCP를 이용하여 전송하고 받는다. 
3. PeerManager 서비스는 peer 들과 연결된 후에 각각 peer 객체를 생성해서 개별적으로 통신하게 한다.
4. BaseProtocol 은 모든 프로토콜이 가져야할 공통적인 메소드를 가지고 있으며, 하위 객체에서 생성하는 각각의 커맨드 프로토콜의 create/send/receive/receive_callback 메소드를 자동적으로 만들어 주는 리플렉션 기봅이 들어가 있다.
5. P2PProtocol 에는 핸드쉐이킹을 통해 상호 프로토콜 정보와 암호정보를 교환하는 프로토콜과 ping,pong 을 통해 연결을 확인하는 명령이 있다. 이 모든것은 MultiplexedSessiion 을 통해서 한다. rlp 인코딩을 한 메세지를 전달함
6. MultiplexedSession 을 통해서 비동기 i/o 를 처리해준다. 즉 read/write 에 대해 각각의 경량쓰레드를 만든 후에 해당 이벤트가 없는 경우 양보한다. read/write 과 RlpxSession 를 관리한다.
7. 또한 MultiplexedSession 을 통해서 보내야할 데이터를 조각낸다. 조각내는 이유는 소켓버퍼의 양(window size)에 맞추기 위함인데, 프로토콜 별로 조각, 우선순위별 조각등이 각각 조합되어 max_window_size 만큼의 패킷을 완성하게 된다. 해당 패킷은 StreamServer 를 통해 보내어진다. 즉 아래 요구사항을 만족. 

  • protocols sharing a connection are provided uniform bandwidth
  • Fair queuing

8. RlpxSession 에서는 RLP 를 통해 전송/수신 메세지에 대해 인코딩/디코딩하고, 상호동일하게 만들어진 비밀키를 통해 암호화 한다. MultiplexedSession 은 이 부분에 대해서 RlpxSession을 이용한다.
9. 위의 다이어그램에는 나오지 않았지만 커넥션이 맺어지고, hello를 하여 핸드쉐이크(프로토콜 및 비밀키 교환)를 완료하고 난 후 부터는 ConnectionMonitor 클래스를 통해서 ping,pong 을 하여 커넥션을 모니터링 한다.

간단히 정리하면 P2PProtocol 은 상위단의 비니지스 로직이고, MultiplexedSession 은 하위단의 패킷 조작자이다.이 모든것을 Peer가 컨트롤하며, 최종 메세지는 PeearManager가 가지고 있는 TCP 소켓(StreamServe)r을 통해서 보내어진다.

주요 소스를 살펴보자. (프로그램이 시작되고 나서 순서대로/몇몇 예외나 로직들이 생략되었다.)

3-1. PeerManager  (peermanger.py 에 존재)

def start(self):
# NAT 문제를 해결하기 위한 upnp 세팅.
self.nat_upnp = add_portmap(
self.config['p2p']['listen_port'],
'TCP',
'Ethereum DEVP2P Peermanager'
)
# TCP 서버의 listening 핸들러 설정. 새로운 접속이 들어오면 호출된다.
self.server.set_handle(self._on_new_connection) # TCP 서버 시작.
self.server.start()
# bootstrap 시작 (이미 하드코딩되어 있는 시작노드에 접속한다)
gevent.spawn_later(0.001, self._bootstrap, self.config['p2p']['bootstrap_nodes']) # discovery 시작 (새로 접속할 노드를 선택하기 위해 kademlia routing table 을 참조한다)
gevent.spawn_later(1, self._discovery_loop)

기본적으로 TCP 서버를 시작하고 있으며, 리모트 노드와 접속하기 위한 위한 경량 쓰레드를 각각 시작한다. 
다음으로 disovery_loop 를 따라가 보자. 


def _discovery_loop(self): # kademlia node discovery 가 어느정도 완성 될 때 까지 대기~
gevent.sleep(self.discovery_delay) # 이제 부터 계속 접속할 노드를 찾는 과정을 반복한다.
while not self.is_stopped:
# 이제 부터 계속 접속할 노드를 찾는 과정을 반복한다.
num_peers, min_peers = self.num_peers(), self.config['p2p']['min_peers'] # 접속할 노드를 선택하기 위해 미리 채워두었던 kademlia 프로토콜을 참조한다.
kademlia_proto = self.app.services.discovery.protocol.kademlia # 접속할 최소peer 수보다 많을 때 까지 추가한다.
if num_peers < min_peers: # 접속할 노드를 선택하기 위해 랜덤으로 node id 하나를 만든다.
nodeid = kademlia.random_nodeid() # kademlia routing table 에서 가까운 노드들을 가져온다.
kademlia_proto.find_node(nodeid) # fixme, should be a task
neighbours = kademlia_proto.routing.neighbours(nodeid, 2)
# 가져온 노드들 중에 노드 하나를 선택한다. 이 무작위로 선택한 노드와 연결될 것이다.
node = random.choice(neighbours)
# 내 public key를 구해서
local_pubkey = crypto.privtopub(decode_hex(self.config['node']['privkey_hex']))
# 선택된 노드와 연결한다. (그 노드에 내 public key 를 보낼 것이다.
self.connect((node.address.ip, node.address.tcp_port), node.pubkey)

Node discovery 프로토콜에서 채워놓은 라우팅테이블에서 가져온 노드들중에 랜덤으로 하나 선택하여 연결한다.
모든 노드들이 이렇게 연결하기 때문에, 토폴로지와 상관없이 골고루 연결될 수 있을 것이다. 아래에 해당된다.

  • nodes have access to a uniform network topology

다음으로 해당 노드와 연결하는 부분을 자세히 살펴보자.

def connect(self, address, remote_pubkey):
# 먼저 gevent 소켓통신 라이브러리에서 제공하는 create_connection 으로 연결정보(소켓디스크립션)를 얻는다.
connection = create_connection(address, timeout=self.connect_timeout) # 이제 해당 노드와 1대1로 통신하기 위한 Peer 객체를 생성한다.
self._start_peer(connection, address, remote_pubkey)
return True

다음으로  _start_peer 를 살펴보자.


def _start_peer(self, connection, address, remote_pubkey=None): # 이제 해당 노드와 1대1로 통신하기 위한 Peer 객체를 생성한다.
peer = Peer(self, connection, remote_pubkey=remote_pubkey)
# peer 경량 쓰레드를 시작한다.
peer.start()

Peer 객체를 시작하고 있다. 인자로는 연결된 상대노드의 public key 정보를 넣어준다. 참고로 이 키는 노드디스커버리 프로토콜에서 얻어졌다. 이제 Peer 객체를 자세히 살펴본다.

3-2. Peer (peer.py 에 존재)
아래는 peer 객체의 생성자이다. 생성자에서는 MultiplexedSession 을 이용하여 리모트 노드와 핸드쉐이크를 통해 상호 신뢰 할 수 있는 연결정보를 확보해 놓는데 이용한다.


def __init__(self, peermanager, connection, remote_pubkey=None):
super(Peer, self).__init__()
self.is_stopped = False
self.hello_received = False
self.peermanager = peermanager
self.connection = connection
self.config = peermanager.config
self.protocols = OrderedDict()


privkey = decode_hex(self.config['node']['privkey_hex']) # P2P Protocol 의 get_hello_packet 클래스메소드를 통해 hello 패킷을 만든다. 이 패킷은 rlp 인코딩되어 리턴된다.
hello_packet = P2PProtocol.get_hello_packet(
self) # MultiplexedSession 을 통해서 보내어 질 최종 메세지를 만들기 위한 각종 작업을 한다.(예를들어 framing) # 또한 앞으로 교환정보를 암호화할 비밀키 생성 및 프로토콜 교환을 위한 작업도 담당한다.
self.mux = MultiplexedSession(privkey, hello_packet, remote_pubkey=remote_pubkey)

3-3. MultiplexedSession(muxsession.py 에 존재)
아래는 muxsession 객체의 생성자이다. 내부에 ingress,egress 큐가 존재하여 multiplexing 작업을 할 수 있으며, RLPxSession 객체를 통해 초기 암호화/프로토콜 공유 핸드쉐이킹을 할 수 있게 된다. 또한 멀티프로토콜 끼리 공평하게 패킷공간을 차지하게 하기 위한 framing 도 시작된다. 

def __init__(self, privkey, hello_packet, remote_pubkey=None): # 내가 시작한 연결인가? 누군가에게요청되어 온 연결인가? # 내가 시작한 연결이라면 노드디스커버리 프로토콜에서 이미 얻어진 상대 public key를 가지고 있을 것이다.

self.is_initiator = bool(remote_pubkey)
self.hello_packet = hello_packet # 나가는 메세지에 대한 큐
self.message_queue = gevent.queue.Queue() # 들어오는 메세지에 대한 큐
self.packet_queue = gevent.queue.Queue() # 암호화/무결성화 에 사용되는 ECCx 객체 생성
ecc = ECCx(raw_privkey=privkey) # 비밀키/토큰 공유를 위한 RLPxSession 객체 생성
self.rlpx_session = RLPxSession(ecc, is_initiator=bool(remote_pubkey))
# 상대노드에게 msg 를 보낸다. (초기 핸드쉐이킹을 위한)
if self.is_initiator:
self._send_init_msg()

이제 _send_init_msg 를 살펴본다.

def _send_init_msg(self): # RLPxSession 객체를 통해 인증메세지를 만든다. 이 메세지는 아주 많은 정보가 포함되는데 # 아래와 같다. # auth_message = S + sha3(ephemeral_pubkey) + self.ecc.raw_pubkey + \ self.initiator_nonce + ascii_chr(flag)
auth_msg =
self.rlpx_session.create_auth_message(self._remote_pubkey) # 인증메세지를 암호화 한다.

auth_msg_ct = self.rlpx_session.encrypt_auth_message(auth_msg) # 암호화된 메세지를 멀티플랙싱을 위한 egress 큐에 넣는다.
self.message_queue.put(auth_msg_ct)


마지막으로 핸드쉐이킹 부분을 살펴보자. 노드에서 초기 핸드세이크를 할 경우에는
add_mesasge 메소드가 add_message_during_handshake 로직을 따르고, 핸드쉐이크가 끝난 후에는 
add_message_post_handshake 로직을 따르게 된다.

핸드쉐이킹은 전반적으로 아래와 같으며, 

# 핸드쉐이킹 도중에는 받은 메세지를 이렇게 처리한다.
def _add_message_during_handshake(self, msg):
session =
self.rlpx_session # 내가 먼저 요청한 연결이라면
if self.is_initiator:
# auth 요청보낸것에 대한 답변인 msg 를 디코드 한다.
rest = session.decode_auth_ack_message(msg) # 리모트에서 넘어온 정보를 토대로 최종적인 상호 암호화 정보를 세팅하게 된다. 앞으로는 구축된 이 정보를 통해 # 상호간 암호화/무결성화된 통신을 하게 될 것이다.
session.setup_cipher()
if len(rest) > 0: # add remains (hello) to queue
self._add_message_post_handshake(rest)
#핸드쉐이킹 요청을 받은 것이라면 else:
# 요청된 auth 정보를 디코딩한다.
rest = session.decode_authentication(msg) # 대답해줄 ack 메세지를 준비한다.
auth_ack_msg = session.create_auth_ack_message()
# 대답해줄 ack 메세지를 암호화 한다.
auth_ack_msg_ct = session.encrypt_auth_ack_message(auth_ack_msg)
# 전송 큐인 message_queue에 넣는다.
self.message_queue.put(auth_ack_msg_ct) # 리모트로 부터 받은 auth 정보를 토대로 cipher 를 세팅한다.
session.setup_cipher()

self.add_message = self._add_message_post_handshake # 핸드쉐이크 종료후 add_messsage 메소드 # 암호화를 위한 핸드쉐이크가 끝나면 이제 프로토콜을 맞춰보는 핸드쉐이크가 시작된다. self.add_packet(self.hello_packet) add_message = _add_message_during_handshake # 초기 add_messsage 메소드 # 핸드쉐이킹이 끝난 후에는 아주 단순하게 그냥 전달받은 msg 를 디코드하여 수신큐인 packet_queue에 넣는다. def _add_message_post_handshake(self, msg): for packet in self.decode(msg): self.packet_queue.put(packet)


첫번째 핸드쉐이킹은 암호화를 위한 정보공유이다. ECDH 를 이용하여 양쪽 노드끼리 동일한 비밀키를 만든다.
리모트에게 보내 줄 auth 메세지를 만드는 아래 함수를 보자.

def create_auth_message(self, remote_pubkey, ephemeral_privkey=None, nonce=None):

if not self.ecc.is_valid_key(remote_pubkey):
raise InvalidKeyError('invalid remote pubkey')
self.remote_pubkey = remote_pubkey

ecdh_shared_secret = self.ecc.get_ecdh_key(remote_pubkey)
token = ecdh_shared_secret
flag = 0x0
self.initiator_nonce = nonce or sha3(ienc(random.randint(0, 2 ** 256 - 1)))
assert len(self.initiator_nonce) == 32

token_xor_nonce = sxor(token, self.initiator_nonce)
assert len(token_xor_nonce) == 32

ephemeral_pubkey = self.ephemeral_ecc.raw_pubkey
assert len(ephemeral_pubkey) == 512 / 8
if not self.ecc.is_valid_key(ephemeral_pubkey):
raise InvalidKeyError('invalid ephemeral pubkey')

# S(ephemeral-privk, ecdh-shared-secret ^ nonce)
S = self.ephemeral_ecc.sign(token_xor_nonce)
assert len(S) == 65

# S || H(ephemeral-pubk) || pubk || nonce || 0x0
auth_message = S + sha3(ephemeral_pubkey) + self.ecc.raw_pubkey + \
self.initiator_nonce + ascii_chr(flag)
assert len(auth_message) == 65 + 32 + 64 + 32 + 1 == 194
return auth_message

이렇게 나의 암호화 정보에 대한 패킷을 만들어 보내면, 상대방은 이것을 디코딩하여 자신에게 적용하고, 다시 나에게 자신의 정보를 ack 로 보내주면 나 역시 상대방의 정보를 이용하여 암호화 토큰을 완성시킨다. 솔직히 이렇게 까지 감싸고 감싸야 하는지에 대해서는 갸우뚱 하다. 

두번째 핸드쉐이킹은 상호 적용 가능한 wire protocol 에 대한 정보교환을 한다. 기본적인 와이어 프로토콜에는 P2PProtocol 이 있다. 

@classmethod
def get_hello_packet(cls, peer):
"special: we need this packet before the protocol can be initalized"
res = dict(version=cls.version,
client_version_string=peer.config['client_version_string'],
capabilities=peer.capabilities,
listen_port=peer.config['p2p']['listen_port'],
remote_pubkey=peer.config['node']['id'])
payload = cls.hello.encode_payload(res)
return Packet(cls.protocol_id, cls.hello.cmd_id, payload=payload)

리모트 노드에 내 정보를 보내 준다. capabilities 에 내가 제공하고 있는 와이어 프로토콜의 정보가 담긴다.

그 후에  리모트로 부터 받은 상대방의 capabilities (와이어프로토콜집합)을 등록하고, 기본 프로토콜(P2PProtocol) 이외의 서비스가 서로간에 존재한다면 그것에 대한 서비스요청을 한다. 이렇게 추가된 프로토콜은 여러 프로토콜이 공평하게 메세지를 전달할 수 있도록 Framming 전략에 따라 패킷을 처리된다. 즉 공평하게 큐에 넣어지고, 균등하게 대역폭이 제공된다.

def receive_hello(self, proto, version, client_version_string, capabilities,
listen_port, remote_pubkey):


self.remote_client_version = client_version_string
self.remote_pubkey = remote_pubkey
self.remote_capabilities = capabilities

# 리모트 서비스를 등록한다.
remote_services = dict()
for name, version in capabilities:
remote_services[name].append(version) # p2p protocol 은 기본적으로 있기 때문에, 다른 서비스가 상호간에 존재한다면 그것에 대한 접속 요청.
for service in sorted(self.peermanager.wired_services, key=operator.attrgetter('name')):
proto = service.wire_protocol
if proto.name in remote_services:
if proto.version in remote_services[proto.name]:
if service != self.peermanager: # p2p protocol already registered
self.connect_service(service)
else:
log.debug('wrong version', service=proto.name, local_version=proto.version,
remote_version=remote_services[proto.name])
self.report_error('wrong version')

자 이렇게 전반적인 devp2p 파이썬 구현체를 살펴보았다. 더 궁금한 점은 직접 소스를 살펴보길 바라며, (Framing 부분도 매우 흥미진진하다는 팁을 귀뜸해드린다.)  다음 글에는 [go,rust,c++에서 구현된 devp2p], [devp2p 를 이용하는 blocksync ],[SWARM vs libp2p 를 이용한 IPFS] 에 대해 말해볼 예정이다.

개인적으로 시장을 장악할 DApp 플레이어로 보고 있는 4가지 플랫폼(이더리움,LOOM,하이퍼레저,코다) + 알파에 대해서 정리 해 보려 합니다.

[작성중..........  틀린 부분도 있을 것이며 당분간은  계속 수정,보안될듯 합니다. ]

 Ethereum

 특징: 가장 지배적인 & 진정한 범용 퍼블릭 체인/ 스웜,플라즈마,위스퍼,캐스퍼등 꾸준한 플랫폼의 발전 / 이더리움입장에서 EOS 는 퍼블릭 블록체인이 아니다. / 수수로가 많으며, 15tps 정도의 저속이다


 스마트컨트랙트 : 솔리디티
 DApp : 자바스크립트등 

 - 블록체인 대표 주자!! 안정적이다. (비트코인은 주춧돌, 뒷방 돈 많은 보수적 할아버지)
 - ETH 코인 & ERC20 & ERC721& 사용자 정의 토큰

 DAppChain 

 (loom network)

 특징: 이더리움의 사이드체인/ERC20 과 호환 토큰/자체 체인을 갖음/ 하드포크를 통한 스마트컨트랙트 업데이트 가능/ FEE 무료/ 고속TPS/ 텔레그램을 통한 한국 개발자와의 적극적인 의사소통. 사용자가 스스로 서브체인 인프라를 만들수도 있고, loom사에서 제공되는 좀비체인도 을 이용할 수도 있다.

 스마트컨트랙트 : 솔리디티
 DApp : 자바스크립트등 

 - 게임,소셜,엔터테이먼트 등등
- 이더리움의 엄청난 효자!! 사이드체인으로 돌파구를 마련했다. 정말 중요한것은 이더리움으로
  아니라면 DAppChain으로 가즈아~
- LOOM TOKEN &  사용자 정의 토큰

 Hyperledger 

 Fabric

 특징: 가장 지배적인 범용 엔터프라이즈블록체인/거의 표준/B2B 시스템이나 국가주도 시스템을 장악 할듯/비교적  고속TPS/권한인증시스템/신원확인,개인정보보호/IBM 및 여러 콘소시엄사들의 지원/플러그인 컨센서스/채널을 통한 멀티블록체인 가능(상호교류는 안됨)


 스마트컨트랙트 : golang, node
 DApp : 자바스크립트등 

 - 공공,제조,무역,유통,기록물보관 등등
 - 변변한 표준이 없어서 규모의 경제를 이루지 못하는 IoT 에 비해서 블록체인에서의 하이퍼레저는 먼가 표준이 되는듯 하다. 저장되어 있는 데이터를 신뢰 할 수 있는 분산데이터베이스!! 목적이 뚜렸하다.
 - 유틸리티 토큰은 만들 수 있다.

 CORDA

 특징: 가장 지배적인 금용권 엔터프라이즈블록체인 중 하나/아키텍트상 금융권에서 사용하기 좋음/ 고속TPS/권한인증시스템/신원확인,개인정보보호/


 스마트컨트랙트 : Kotlin,Java
 DApp : Kotlin,Java,자바스크립트등 

 - 금융
 - 관련되는 사람만 공유하는 특징적인 장부시스템 덕분에 하이퍼레저 패브릭에서는 힘든 Privacy 구현이 가능하다. 패브릭에서 1.2에 Private Data라는 것을 도입했지만 근본적으로는 CORDA를 따라잡을 순 없다. 지향하는 바가 다름 

 EOS

 특징: 이더리움을 가장 위협하는 범용 퍼블릭 체인/고속TPS/FEE 무료


 스마트컨트랙트 : C++, WASM
 DApp : 자바스크립트등 

 - 게임,소셜,,엔터테이먼트 등등
 - 이더리움의 약점(?)을 커버(?) 하였다고 주장하여, 굉장한 경쟁자가 되었다. EOS 와 이더리움기반의 사이드체인의 싸움이 앞으로 볼 만 할 것이다.
- EOS Coin 

 STEEM,

COSMOS,
NEO,
오미세고

 - STEEM  SMT

 소셜부분에 특화된 블록체인인데, SMT 가 나오면 더 좋아지긴 하겠는데, 모든것을 다 할 수 있는 (?) EOS의 메인넷이 출범하면서 굳이 약소한 형제(?) 인 STEEM 으로 해야하나 싶기도 하다. 

 - COSMOS ABCI Application

 코스모스는 각각의 독립 체인들을 연결시키는 기능을 제공한다. 즉 체인간의 연결고리를 만들어주는 DApp 개발을 만들 수 있을 것이다. 거래소도 그중 하나 일 것이고~

 - NEO NNC
  중국판 이더리움이라 불리며(기술적으로는 완전 다름) 스마트 이코노미(Smart Economy)"를 목적으로 두고 있는데 굉장히 적극적으로 개발되고 있다.  이더리움과는 다르게 신원증명과 결정성(DBFT합의방식)을 보장한다. 즉 다양한 자산에 대한 처리를 잘 할 수 있도록 하는데 중점을 두고 있다. Hyperledger 의 퍼블릭판 같은 느낌이다. 초당 거래 건수는 1000tps 로 이더리움의 15tps 에 비해 고속이며, 최소한의 수수료가 있다.(이더리움에 비해서 작다) 현재 지원하는 언어만해도 1) C#, VB.Net, F#,Java, Kotlin,Python 이며 향후 지원될 언어들은 다음과 같다. C, C ++, GO,JavaScript 상당히 개발자 친화적이다.

  - 오미세고 OMG
오미세고는 오픈소스 퍼블릭 블록체인인 이더리움의 사이드체인 오픈 결제 플랫폼이다.이 프로젝트의 슬로건은 "Unbank the Banked" 이고 목적은 전통적인 은행 시스템을 이용하는 사람과 은행 시스템의 인프라가 없는 개발도상지역의 사람을 포함한 모두에게 더 나은 금융 서비스를 제공하기 위함이다. 멋지네 ㅎㅎ먼가 핀테크 쪽의 DApp이 나올거 같은데..we are pleased to announce that the repositories for the OmiseGO eWallet, as well as our first server and mobile SDKs, are now publicly available for the purpose of onboarding eWallet providers. 이렇게 말하고 있다.일반적인 DApp 이라기보다는 오미세고 네트워크를 활용하는 전자지갑을 만들 수 있는 SDK 를 제공하는듯하다.  [링크]


부록) 블록체인 플랫폼 간 데이터 저장 비용 

SizeRecordsKeeperBitcoinEthereumNeoEOS
1 KiloByte$ 0.00894 USD$ 3.835 USD$ 3.86 USD$ 19.38 USD$3 USD
1 MegaByte$ 8.94 USD$ 3866.86 USD$ 3954.49 USD$ 19742.72 USD$ 3072 USD

* Based on USD price on 15 April, 2018  (참고:  recordskeeper )

이더리움에서는 각종 함수콜에 관련된 용어와 용도가 난무하여 혼동을 가중 시키고 있는데요. 일반적으로 트랜잭션은 쓰기를 말하고, 콜은 읽기를 말합니다. (CQRS처럼 커맨드와 쿼리가 더 일반적이죠) 하지만 이것은 너무 대략적으로 말한것이라 좀 더 구체적으로 구분해 볼 필요가 생겨서 본 글을 쓰게 되었습니다. (오류가 있을 수 있으며, 이더리움이 업데이트 될 수 있습니다. 재진입같은 보안 이슈는 다루지 않습니다.) 

호출 방식 


1. Message (Internal tranaction)
 
:
  컨트랙트에서 컨트랙트로 호출 하는 것 or 컨트랙트에서 EOA 로 이더전송. 채굴 할 때 까지 대기하지 않으며 즉시 호출.  처음 발생되는 Transaction 호출에 의해 일어나는 부분이다. 따라서  message 에 소모되는 가스 비용은 처음 tranaction 에 포함된다. 최대 1024 Depth 까지 호출될 수 있다.

   1-1. 컨트랙트 -> 컨트랙트에는 로우레벨적으로 (메세지) Call 과 (메세지) DelegateCall 이 있다.  

 Delegatecall은 호출하는 컨트렉트의 context에서 타켓 주소의 코드가 실행되고 msg.sender와 msg.value의 값이 변경되지 않는다는 점을 제외하면  그냥 메시지 콜과 동일하다. (사실 큰 차이이다) 이는 컨트렉트가 실행될 때 다른 주소로부터 코드를 다이내믹하게 읽어들일수 있다는 것을 의미한다. 저장 장소, 현재 주소, 잔액은 여전히 호출하는 주소를 참조하고, 코드만이 호출된 주소로부터 읽어 들이게 된다.   msg.sender / tx.origin  는 최초 호출한   EOA 가 되며,  this 는 호출한 컨트랙트가 된다. 이는  library함수를  호출할 때와 동일한데  library 를 그냥 콜할때 내부적으로  delegateCall이 호출되기 때문이다.  (간단하게 말하면 일반 컨트랜트간 일반 CALL은 저장소 니꺼 사용,  DelegateCall은 내꺼 사용) 

 
   1-2. 
컨트랙트에서 이더 전송 에는 SEND/TRANSFER/CALL 이 있다. 모두 OPCODE 로는 CALL 이다. (

 send is now deprecated.)

- (opcode) call 함수가 value와 함께 호출될 때 call함수는 받은 모든 가스를 전달한다. 즉 send/transfer 는 2300가스 고정이고, call 을 이용하면 set(gaslimit) 할수 있다. 
-  (opcode) call 을 직접적으로 사용해서 컨트랙트를 호출하지 않는것을 권장한다. 그냥 함수이름으로 호출 ㄱㄱ 

 

2. Transaction:  EOA 에서 호출하는 것


2-1. sendTransaction:
  네트워크에 보내지며 채굴에 의해 확인 받아야 한다. 서명이 필요하며, 채굴 받될 때까지 결과를 얻을 수 없다.  가스를 소모하며 보통 상태를 변경하다. ( 새로운 컨트랙트 만들기, 컨트렉트 호출,  다른 EOA 로 이더보내기)

sendTransaction vs sendRawTransaction

sendTransaction 는 매개변수로 from Address가 필요한데, 트랜잭션에 필요한 개인키를 그걸 통해서  찾기 때문이다. Ethereum client에서 관리하는 개인키를 찾아서 사인해 주세요의 의미이다.
sendRawTransaction 는 from Address가 필요없는데, 개인키 관리를 클라이언트가 알아서 하기 때문이다. 지갑같은 것을 통해 자기가 직접 가져와서 트랜잭션을 직접 서명함. 서명된 데이터(그 안의 private key) 로 부터 public key가 추출되고 그것으로 부터 from Address도 추출되기 때문에 굳이 매개변수로 넣어주지 않는 것이다.

 

2-2. call:  자신의 노드에서만 실행된다. read-only로 실행되며 즉각적으로 결과를 알 수 있으며, 비용이 들지 않는다. 

* yellow paper 에 나오는 "메세지 콜" 이라는 용어는 혼동을 초래하므로 제외 하는게 좋다. 위에 말했다시피, call은 컨트랙트간 호출에서도 사용되는 용어이며, 컨트랙트에서 EOA로 이더를 전송하는 OPCODE에도 CALL이 있다.


아래 3가지 web3 호출 의 차이점

1.testInstance.testfunc({from:eth.accounts[0]}) 2.testInstance.testfunc.sendTransaction({from:eth.accounts[0]}) 3.testInstance.testfunc.call({from:eth.accounts[0]})

  1.  testfunc 가 constant 라벨이 붙었으면, #3번과 같고, 그렇지 않다면 #2와 같다. 
     * 
    solc 0.4.17 부터 제거 되었으며, view 나 pure 를 사용한다. 
  2.  위의 구분에서 sendTransaction 를 말한다. 
  3.  위의 구분에서 call 을 말한다. 


정리 

 


* OP 코드에 따라 아래와 같은 가스 소모가든다. 

Operation Name Gas Cost Remark
step 1 default amount per an execution cycle
stop 0 free
suicide 0 free
sha3 20  
sload 20 get from permanent storage
sstore 100 put into permanent storage
balance 20  
create 100 contract creation
call 20 initiating a read only call
memory 1 every additional word when expanding memory
txdata 5 every byte of data or code for a transaction
transaction 500 base fee transaction
contract creation 53000
changed in homestead from 21000

 


- 앞으로 이더리움 자체에서 코딩할 일이 대부분의 블록체인 개발자의 경우 없어 질 거라 본다. (이유:사이드체인) 
- 사이드체인은 비용 걱정할 일이 별로 없다. 메인체인과 인터렉션 할 중요한 경우만~~ 
- 컨트랙트를 올리는 비용은 크다. (코드 저장비용이 들어가니깐) 컨트랙트를 호출 하는 비용에 비해~ 
- 데이터 저장에 들어가는 비용은 계산에 들어가는 비용보다 매우 크다.
- 컨트랙트 실행시 가스가 모자르면 그 동안 사용한 가스는 모두 몰수하고 원상복귀다. (예외 발생) 
- 송금을 포함한 모든 트랙잭션은 기본적으로 21000가스가 소모되며, 컨트랙트 호출은 +알파(코드실행)가 든다.
- view 키워드가 붙으면 외부에서 호출하는 경우 비용이 들지 않는다. 자기 로컬에서 만 호출하니깐~ (트랜잭션이 아님)
- view 키워드가 붙었지만 내부(동일한 컨트랙) 에서 호출하는 경우, view 성질은 없어지며, 계산 비용이 들어간다. 
- 컨트랙트간 호출에서도 상대 컨트랙트의 view 함수를 호출하면 비용이 들지 않겠지. 
- 컨트랙트간 호출(메세지콜) 에 들어가는 비용은, 애초에 EOA 에서 호출 할 때 계산되어져야 한다.
- call 은 내부에서 놀고 (비용이 안든다) , 트랜잭션은 외부에서 논다. (기본 비용 등등 든다) 
- memory 키워드를 사용하여 비용을 절약하라. 함수외부는 디폴트가 storage 이고, 내부는 memory 지만, 구조체와 배열을 처리하는 경우라면 내부라도 명시적으로 써야 한다. (단순한 복사는 memory, 참조 해서 변경하려면 storage)
- 온갖 mapping 구조를 만들어서 저장공간을 낭비하지말고, 그냥 기본 구조의 loop 돌려서 새로 만들어서 리턴하라.예를들어 (id, kind) (id, count) (kind,count) 등 많이 만들어 두면 get 호출시 속도가 빨라져 좋았었잖아? 근데 블록체인에서는 가격이 더 문제다. 온갖 구조에서 변경이 일어 날때 쓰기 비용은 어마어마 할 것이다. 
-  address.send 나 address.transfer 의 이더전송 호출에 들어가는 가스는 2300이다. address.call.value(amount)( ) 를 통한 이더전송 호출은 가스를 세팅 할 수 있다. 
-  새로 변수 할당 할 때 20000가스, 변수의 값 바꾸는데 5000가스 
- 1mb 데이터를 쓴다고 할 때 대략 word 기준 (32byte) 32,768  번 쓰니깐 5 * (20,000 * 32,768) / 1,000,000 ETH, or ~ 3.7628 ETH.  이더당 500~600달러라고 하면, 대략 한국돈으로 150~2백만원 정도 하겠다. 1기가면 몇십억이다. 




chan chan 이란?

블록체인을 비롯해서 서버쪽 개발 언어로 go 언어가 엄청나게 부상하고 있는데요. 이 글에서는 go 언어의 가장 큰 특징 중 하나인 고루틴/채널에 대하여 간단히 설명하며, 좀 더 고급기술인 채널 위에 채널을 얹는 문법을 이더리움 코드를 통해서 살펴봅니다.

golang은 C,C++ 보다 실전적으로 고성능이라고 생각하는데, 그 근거가 바로 고루틴과 채널입니다. 물론 C++도 쓰레드가 있긴 하지만 코딩 할 때, 아~ 이 부분은 좀 시간 걸리겠는데 하더라도 쓰레드로 빼긴 부담스럽게 느껴지는게 사실이며, 많은 C++ 개발자들이 그러한 습관에 코딩을 하고 있습니다. 하지만 GO언어는 언어자체에서 경량쓰레드를 지원하므로, 너무나도 자연스럽게 쓰레드화하고, 채널을 통해서 그것의 결과에 해당하는 값을 받아 볼 수 있게 하고 있습니다.CPU를 너무나 적극적으로 사용하고 있는 이 과정이 너무 쉽기 때문에 golang 으로 짜여진 오픈소스를 보면 고루틴,채널이 범벅되어 있는 것을 많이 볼 수 있습니다. (아무리 쉽다고 해도 고루틴,채널 범벅이면 시리얼한것보다는 복잡해 보이는것은 사실입니다) 혹시 고루틴과 채널에 대해서 처음 듣는 것이라면 제가 예전에 번역한 고 언어에서의 동시성 모델(CSP) 를 먼저 읽고 이 포스트를 보시면 좋을 거 같습니다.

(혹시 고성능이 무조건 최고다라고 생각하시는 분은 없었으면 합니다. 0.0000001초가 걸리던 0.00001초가 걸리던, 일반적인 현실에서는 0.01초만 걸려도 아무 상관없는 솔루션이 대부분이기 때문입니다. 성능보다는 다른 지표로 언어를 선택해야하는 경우가 더 많습니다.그래서 파이썬이 잘나가는거겠지요.^^) 

고루틴

경량쓰레드인 고 루틴을 만드는것은 너무나 간단하다. 로직을 짜다가 이것은 조금 시간이 걸리겠다 싶으면 그냥 고루틴으로 만들 수 있다. 


func main()
{
go doSomthing() // GO 루틴 시작

}

func doSomthing()
{
.... 무엇인가 한다 ...
}


이렇게 일반 함수 앞에 go 라고 붙히기만 하면 경량 쓰레드로 동작한다.

채널

저렇게 고루틴을 만들고 나면, 고루틴에서 어떤 작업을 했을 때, 그 결과를 받고 싶을 수가 있다. 보통 다른 언어로는 동시성을 보장하는 큐를 만들어서 매개변수로 보내서 처리 할 텐데, (혹은 이벤트 동기화) 고 언어에서는 더욱 간편한 것이 있다. 이걸 채널이라고 부르는데 이거 덕분에 고언어에서 쓰레드를 사용하는 것은 더욱 쉬워 지며, 안정적이게 된다. Mutex 를 사용하지 않고 액터패턴처럼 구현되기 때문인데 아래에 예를 살펴보자.


func main()
{
ch := make(chan int) // 통신용 채널 생성

go sendingGoRoutine(ch) // GO 루틴 시작
go receivingGoRoutine(ch) // GO 루틴 시작
..
}
// 송신 코루틴
func sendingGoRoutine(ch chan int)
{
... 어떤 작업을 함 ...

ch <- 45 // 결과 값을 보냄.
}
// 수신 코루틴
func receivingGoRoutine(ch chan int)
{
// 송신 채널로부터 값을 올때까지 기다리다가 오면, v 에 입력
v := <- ch
fmt.Println("Received value ", v)
}

ch := make(chan int) // 통신용 채널 생성

채널은 위와 같이 만들며 int 타입을 전송할 수 있는 채널을 만든 다는 것이다.
생산자가 ch 에 어떤 값을 쓰면, 소비자는 ch 을 기다리다가 값이 들어오면 처리한다.
위의 예에서 생산자는

func sendingGoRoutine(ch chan int)
{
   ...
  ch <- 45
}
이것이 되며, ch <- 45 , 즉 채널에 45를 송신해 주고 있다.
소비자는
func receivingGoRoutine(ch chan int)
{
   // 채널로부터 값을 받으면 v 에 입력
   v := <- ch
   fmt.Println("Received value ", v)
}
이것이 되며, v := <-ch , 즉 채널로부터 값을 수신 받기를 기다리다가 값이 들어오면 처리한다.


chan chan 이란?

requestChan := make(chan chan string)

자 채널에 대해서 공부해 봤는데. chan chan 이렇게 두개를 연속으로 사용 할 수도 있다.무엇 일까?
채널에 값을 보내는게 아니라, 채널에 채널(문자열을 통신하는)을 보내고 있다. 즉
chan 이 "보내면 받을께" 라면 
chan chan 은 "내가 준비됬다는것을 알려줄께 기다리다, 내가 보낸 채널을 통해 보내" 이다. 양방향 처리를 할 수 있다.
아래 그림을 보자. 고루틴 C (소비자 역할)는 고루틴 D (생산자 역할) 에게 "내가 준비 됬다는 것을 chan chan채널을 통해서 알려주면, 채널을 통해 내가 보낸 채널을 통해 니가 나한테 생산한것을 건네줘" 라고 하는 것을 도식화 한 것이다.

Screenshot

1. 고루틴 C 와 고루틴 D은 chan chan 채널(requestChan := make(chan chan string)을 함께 가지고 있다.

Screenshot

2. 고루틴 C (소비자) 는 처리 할 준비가 되었다고, 알려주며 이 채널 (여기선 response 채널)을 통해서 보내라고 고루틴 D에게  말해준다.

Screenshot

3. 고루틴 D (생산자) 는  고루틴 C에게 받은  채널 (여기선 response 채널)을 통해서 고루틴 C에게 문자열을 보낸다.

package main

import "fmt"
import "time"

func main() {

requestChan := make(chan chan string)

go goroutineC(requestChan)
go goroutineD(requestChan)

time.Sleep(time.Second)

}

func goroutineC(requestChan chan chan string) {

responseChan := make(chan string)

requestChan <- responseChan

response := <-responseChan

fmt.Printf("Response: %v\n", response)

}

func goroutineD(requestChan chan chan string) {

responseChan := <-requestChan

responseChan <- "wassup!"
}

소스를 통해 명확히 이해 할 수 있을 것이다. 키포인트는
requestChan <- responseChan  채널에게 값을 전송하는게 아니라, 채널을 전송하는 지점이다. 

이더리움 오픈소스에서 chan chan 은 어떻게 사용 되었나? (소스)

이더리움에서 p2p 시작 부분을 살펴보면, (kademlia 알고리즘에 따라) 다른 노드를 찾기 위한 요청을 하기 전에 자신의 버켓을 다시 채우는(리프레쉬)과정이 있는데, 여기서 리프레쉬를  고루틴(쓰레드)화 해서 처리하고, 리프레쉬가 끝나면 끝났다고 알려주길 바라는 부분이 있다. 이때 chan chan을 통해서 처리하는데, 소스를 통해 확인해 보자.

refreshReq: make(chan chan struct{}),

 refreshReq 라는 chan chan을 만듭니다. 변수 이름이 말해 주듯이 리프레쉬를 요청하는 채널인데, 위의 예제와 마찬가지로 이 채널에 response에 해당하는 채널을 건네주고, 대기 하는 로직 일 수도 있겠다.

func (tab *Table) refresh() <-chan struct{} {
    done := make(chan struct{})
    select {
    case tab.refreshReq <- done: <-- 이 부분
    case <-tab.closed:
        close(done)
    }
    return done
}

response 는 아니고, done 이라는 채널을 만들어서 건네주고 있다. 리프레쉬를 한 다음에 완료되면 done 채널을 통해 보내라는 것 같다. 그럼 누가 받을 까? 리프레쉬를 진짜 실행하는 놈이 받겠지. 그리고 리프레쉬 하는 놈이 done에 어떤 것을 보내면 그것을 처리 하기 위해 위의 return done 을 받은 녀석은 어디선가 대기하고 있을 것이다.


// loop schedules refresh, revalidate runs and coordinates shutdown.
func (tab *Table) loop() {
    ...
    for {
        select {
  
        case req := <-tab.refreshReq: <--- 이 부분
            waiting = append(waiting, req)
            if refreshDone == nil {
                refreshDone = make(chan struct{})
                go tab.doRefresh(refreshDone)
            }
       ...

loop 함수에서 req := <-tab.refreshReq:  이렇게 받고 있다. 받은 것을 waiting 배열에 넣어서 보관하며
진짜 리프레쉬를 하는 go tab.doRefresh(refreshDone)  함수를 호출하고 있다.

    case req := <-tab.refreshReq:
            waiting = append(waiting, req)
            if refreshDone == nil {
                refreshDone = make(chan struct{})
                go tab.doRefresh(refreshDone)
            }
        case <-refreshDone: <--- 이 부분
            for _, ch := range waiting {
                close(ch) <--- 이 부분
            }
            waiting, refreshDone = nil, nil

go tab.doRefresh(refreshDone) 부분을 보면, refreshDone 이라는 채널을 매개변수로 넣어주는데, 예상하듯이 리프레쉬가 완료되면 이 채널로 완료 보고를 하라는 것이고,  case <-refreshDone:  <--- 이 부분 여기서 완료 보고를 기다리고 있다.
그 아래 close(ch)가 있는데, 여기서 진짜 처음 소스에 있는 done := make(chan struct{})이 done에 대한 완료 보고를 하고 있다. 즉 모든 처리가 끝났다고 알려 준다. 그럼 done 을 보고 받는 마지막 코드를 보자.

func (tab *Table) lookup(targetID NodeID, refreshIfEmpty bool) []*Node {
....
    for {
        tab.mutex.Lock()
        // generate initial result set
        result = tab.closest(target, bucketSize)
        tab.mutex.Unlock()
        if len(result.entries) > 0 || !refreshIfEmpty {
            break
        }
        // The result set is empty, all nodes were dropped, refresh.
        // We actually wait for the refresh to complete here. The very
        // first query will hit this case and run the bootstrapping
        // logic.
        <-tab.refresh() <--- 여기 대기
        refreshIfEmpty = false
    } .. look up 처리 ..

위의 코드를 보면 tab.refresh() 를 호출하고 리프레쉬가 완료되길 기다리고 있다. 즉 리프레쉬가 완료 된 후에야 lookup을 처리하겠다는 것이다. 

소스를 보면 아시다시피, 고 언어에서 고루틴/채널은 정말 너무 사소하게 사용 된다. 그러다보니, 채널의 채널이 생기고 그 그 채널을 처리 하기 위해 또 고루틴을 만들고 그것을 위한 채널을 또 만들고...멀티코어 를 적극적으로 사용하는 모습이 기본이며, 그래도 고 언어이기 때문에 그나마 복잡도가 많이 줄어 든거 같긴 하다. :-) 



레퍼런스:
http://tleyden.github.io/blog/2013/11/23/understanding-chan-chans-in-go/
https://github.com/ethereum/go-ethereum/blob/master/p2p/discover/table.go

블록체인은 전세계적으로 분산되어있는 노드들간에 합의를 이루는 과정을 통해서 신뢰를 확보하는데요.  이때 처음 내 컴퓨터에 있는 이더리움 프로그램이 켜질때 어떻게 전세계의 컴퓨터들과 연결되는 걸까요?그것에 관련된 글을 적어 보았습니다.

(이 글은 의문을 품고 공부하면서 동시에 작성되어 두서가 없습니다. 또한 잘못된 정보(혹은 업데이트 되지 않은 정보)가 포함 되어 있을 가능성도 있음을 알려 드립니다.)

이더리움에서 흩어져 있는 노드를 찾을 때 Kademlia DHT 의 일부를 수정해서 사용한다고 하는 글을 보았을 때 좀 이해가 안 갔는데, 이유는 Kademlia은 토렌토등에서 사용되며 특정 노드/ 특정 값을 찾고, 일부 노드에만 분산 저장하기 위한 알고리즘인데, 이더리움도 비트코인처럼 그저 랜덤 하게 설정되는것으로 추측 했었기 때문이다. 특정 노드 / 특정 값을 찾기 위함이라면 이더리움 스웜 (IPFS 와 비슷하며, IPFS 는 그 용도로 이미 Kademlia 를 사용중) 이 말이 되지, 일반 이더리움에서는 어떻게 적용 된다는 말인지 궁금했다. 따라서 좀 더 정확히 알아 보기로 했다.

(비트코인의 경우 마스터링 비트코인에는 '종자노드' 로부터 새로운 노드들의 정보를 얻고, 그 노드들과 연결된 노드로 전파되어 내 노드 정보를 전파하는 동시에 , 연결에 연결된 노드들의 정보를 알 수 있다. 정도로만 나와서 그 많은 노드 정보 중에 선택되는 노드의 기준이 무엇인지 그 책 만으로는 알 수가 없다. 따라서 랜덤으로 생각하고 넘겼는데..이 글을 쓰면서 찾아보니 진짜 랜덤이네. 그렇다면 Kademlia 의 자신과 가까운 녀석들의 정보를 많이 가지고 있게 하는 것이, 적은 노력으로 골고루 분포 될 수 있게 하는 힘이 되지 않을까 싶었다.) 

먼저 이더리움에서 사용되고 있다는 카뎀리아에 대해서 알아보자.

Kademlia 

Kademlia 는 아래와 같은 4개의 프로토콜이 있다.

  • PING — 상대 노드가 여전히 살아있는지 확인
  • STORE — 노드에 (키,값) 쌍을 저장
  • FIND_NODE — 자신의 버킷에 있는 k개의 노드들 (요청된 노드에 가장 가까운)들을 리턴 한다. 
  • FIND_VALUE — FIND_NODE 와 동작방식이 같으나, 해당 키가 노드ID가 아니라 저장소에 있다면, 해당되는 값을 리턴 해준다. 


FIND_VALUE 와 STORE 는 데이터 분산 저장에 관련된 내용이니 분명히 이더리움에서는 (스웜에서는 사용되겠지만) 제외하고,  FIND_NODE 와 PING 만 노드 디스커버리를 위해 사용되리라 추측 할 수 있겠다.

이에 따라서 Kademlia 에서 사용하는 노드 디스커버리에 한정 해서 실생활 상황으로 설명 해 보겠다. , 

제주도 서귀포 출신 A군이, 광화문 교보문고를 찾아 가려면, 일단 서울역 가는 방법만 알면되고, 그 후에 서울역에서 광화문 가는 방법을 물어보면 될 것이다.  광화문에 가서 그 근처 사람에게 물어보면 교보문고를 찾을 수 있을 것인데, 그 근처의 지리는 그 근처의 사람이 더 잘 안다는 매우 단순한 논리에서 시작된다. 

다시 반복하자면 제주도 서귀포 출신 A 군은 서귀포 근처(거리 상 가까운 지리)에 대해서 잘아는 친구들이 서귀포 각 지역마다 있을 것이다. 서울,대구,부산,광주에 대해서는 그 넓은 지역의 친구 한 명 만 알면 된다. A군이 서울의 신촌의 특정 맛집을 찾는 방식은 서울 친구에게 물어보면 그 서울 친구는 자기 주변의 친구 중에 신촌 근처에 사는 친구의 전화번호를 A군에게 알려주면 될 것이다. 신촌 근처사는 B군이 제주도 특정 맛집을 찾을 때도 마찬가지다. 먼저 아까 교환한 전화번호로 A군에게 전화하면 A군은 그 지역 제주도 친구의 전화번호를 건네 주면 될 것이다. 

각 노드가 모든 노드를 알 필요 없이 자기와 가까운 노드에 대해서는 세밀히, 먼 노드에 대해서는 대표격으로 몇개만 안다면, 몇 단계만 거치면 서로서로 찾아 갈 수 있다는 것이며, Kademlia 의 주요 특징은 그 거리를 XOR 연산으로 한다는 것이다. (즉 지리적인 토폴로지로 가까운 노드를 찾는 것이 아님. 이 부분은 문제가 될 소지가 있어 보임. 한국에서 미국 노드와 연결되 있으면 아무래도 추가 부담이 들어가니까)

즉 노드A의 ID 가 1111 이고 노드 B의 ID가 0111 이고 노드 C의 ID가 1110 이면 A와 B의 거리는 굉장히 먼것이고, A와 C의 거리는 매우 가까운 것으로 치고, 자기와 가까운 거리의 노드의 정보에 대해서만 많이 가지고 있고, 멀리 떨어진 정보는 대표노드 한두개씩만 가지고 있는 방식이다.  log2 n 쿼리만으로도 모두(아무것이나) 찾을 수 있다.위 그림에서 빨강색 노드인 0011 이 1100 노드를 찾기 위해서는 1 로 시작되는 모든 노드 정보를 알 필요 없이 1로 시작되는 노드 하나 만 알면되고, 그 노드를 통해 1100의 행방을 수소문 하면 되는 것이다. 결론적으로 Kademlia 는 모든 정보를 가지고 있을 수 없는 거대한 집합군에서 특정 노드를 빠르게( O(log2(n)) 찾기 위한 방식인 것이다. 


이더리움에서 노드 디스커버리?

일단 이더리움에서 Kademlia 를 사용한다는 것은 잊어버리고, 어떻게 내 컴퓨터에서 이더리움을 시작하면 ,연결되어야 할 노드들을 알 수 있는지  알아보자. 

먼저 구글링을 통해 스택오버플로우의 https://ethereum.stackexchange.com/questions/7743/what-are-the-peer-discovery-mechanisms-involved-in-ethereum  답변을 발견했는데, 아래의 순서로 p2p를 시작한단다.

1. 부트스트랩 노드에 대해 이미 하드코딩되어 알고 있다. (부트스트랩 노드는 항상 활성화 되어 있다고 가정한다)
2. 부트스트랩 노드는 얼만큼의 시간 안 에 그들과 접속한 모든 노드(즉 최신의)들의 리스트를 가지고 있다. 
3. 내 노드가 이더리움 네트워크에 접속 할 때 부트스트랩 노드에 먼저 접속해서 최신의 노드들에 대한 정보를 공유한다. (내가 접속하는 순간 나도 그 리스트에 들어가며, 남들이 나에게 연결 할 수 있게 될 것이다)
4. 부트스트랩과는 빠이빠이하고, 정해진 숫자의 공유된 노드 리스트에게 연결한다.

일단 이 답변에서는 Kademlia의 역할이 확실히 보이지는 않는다. 항상 부트스트랩 노드에 의지해서 연결 될 노드를 찾지는 아닐 것이며, 자신의 노드에 예전에 연결된 노드들이 있고 그 노드들의 상태를 확인해서 문제 없으면 다시 연결 할 것이고, 모든 노드에 연결이 안되면, 그때 부트스트랩 전용 노드를 통해 다시디스커버리를 할 것으로 추측 할 수 있다.기존에 가지고 있던 노드들 중 하나만 접속된 다면 이 녀석에게 노드 디스커버리 프로토콜을 적용 해서 추가 노드를 찾을 것이다. 모든 노드가 라우팅테이블을 가지고 있기에.

일단 이 정도 추론해보고, 실제 이더리움에서는 어떻게 구성이 되어있고, 활용되는지 관련 문서를 번역해보고, 소스도 살펴 보았다.

Network Formation 

  • 새로운 노드는 신뢰 할 만하게 접속 해야할 노드를 찾을 수 있다.
  • 노드들은 다른 노드들과 균일하게 접속하기 위해 충분한 네트워크 토폴로지를 갖는다.
  • 노드 구분자는 랜덤이다.

RLPx 는 p2p 이웃노드탐색프로토콜로써 사용된 Kademlia 식의 라우팅을 이용한다. RLPx 탐색은 XOR를 위해 512-bit 공개키를 이용하는데  DHT 전체 특징은 구현되지 않았다.  나의 노드가 인터넷에 직접 연결되어 있거나, UPnP 옵션이 있다면 연결 요청을 받을 수도 있을 것이다.

Implementation Overview 

패킷들은 동적으로 만들어지고, RLP 엔코딩된 헤더가 앞에 붙으며, 암호화되고, 인증된다. 멀티플렉싱이 패킷의 목적지 프로토콜을 명시한 프레임 헤더를 경유해 작동 된다.  모든 암호계산은 secp256k1로 하며, 각 노드는 정적 개인키를 유지하여야 한다.

RLPx 구현은 다음과 같이 구성된다:

  • 노드 디스커버리 <-- 이번 포스트에서는 이것만 집중 
  • 암호화된 전송 
  • 프레이밍(Framing)
  • 흐름 컨트롤

Node Discovery  

노드 디스커버리와 네트워크 형식은 Kademlia-like UDP 식으로 구현되어 있다. Kademlia 와의 주요 차이점은 

  • 패킷들은 서명된다.
  • 노드 아이디는 공개키이다.
  • DHT 관련 몇몇 특징이 제외 되었다. (FIND_VALUE 와 STORE)
  • XOR 거리는 sha3(nodeid)에 기반하여 계산된다.

노드 아이덴터티 

이더리움 각 노드는 공개키(256-bit hash (sha3/Keccak-256)는 해당 노드의 ID 로 사용된다. 모든 노드의 이진 배열 길이는 동일하기에 XOR로 길이를 구하는 토대가 된다. 두 노드간의 거리는 아래와 같다.

istance(n₁, n₂) = keccak256(n₁) XOR keccak256(n₂)

노드 테이블 (* 이더리움 특화 말고 일반적인 설명)

각 노드는 자신의 이웃에 해당 하는 다른 노드들에 대한 정보를 유지하고 있다. 모든 노드는 라우팅 역할을 한다고 볼 수 있겠다.이 이웃노드들은 'k-bucket' 이라고 하는 라우팅 테이블을 보유하고 있는데 구조는 다음과 같다.

- 하나의 버킷은  2^i ~2^(i+1)  사이의 값을 갖는 값들로 이루어져 있으며, (0 ≤ i < 256 ) 개의 버킷가 있다.
- 자신의 노드와 다른 노드의 거리 값을 구해서 그 값에 해당 하는 값이 6이면,  2^2~ 2^3 사이의 버킷에 들어간다.
- 각 버킷에는 k 개의 노드 정보(ip,port,nodeid)들을 가지고 있다.( k 는 redundancy 라고 하며 이더리움에서는 k = 16) , 즉 2^10~ 2^(11) 사이의 값도 16개를, 2^100~ 2^(101) 사이의 값 중에서도 16개를 가지고 있으니, 자신과 가까운 노드들일 수록 자세하게 가지고 있는게 확인 된다. 이것은 글 초기의 제주도 서귀포 출신 A군의 이야기를 다시 생각해 보면 쉽게 이해 할 수 있을 것이다. 

* 위의 이미지(설명)는 이더리움에서 사용되는 노드테이블 구조와 정확히 매칭되진 않는다. 이더리움은 조금 변경하였다. 실제 코드에는 아래와 같이 설정되어있다.

   bucketSize      = 16 // 하나의 버켓에 담기게 될 노드 숫자.

// We keep buckets for the upper 1/15 of distances because
        // it's very unlikely we'll ever encounter a node that's closer.

hashBits          = len(common.Hash{}) * 8
        nBuckets         = hashBits / 15       // 버킷의 갯수 


테이블 안의 노드 정보는 아래와 같이 업데이트 된다.

새로운 노드 (N1)를 만날 때 마다, 대응하는 버킷에 삽입 될 수 있다. 버킷이 k 개 미만의 엔트리를 포함하면, N1은 그냥 첫번째 엔트리에 추가 되며, 버킷에 이미 k 개 항목이 포함 되어 있으면 버킷의 가장 최근에 본 노드 N2에 ping 패킷을 보내서 유효성을 다시 체크하고 N2에서 응답을 받지 못하면 죽은 것으로 간주헤 제거하고 N1이 버킷의 첫번째 엔트리에 추가한다. 

노드 찾기 및 라우팅 

특정 노드ID에서 가장 가까운 K (k closest nodes) 개의 노드의 위치를 찾아(lookup)보자. 참고로 여기서 k 는 위의 k-bucket 의 k (k=16개였던)와 의미가 다르다. 

첫째. 보통 타겟에 가장 가까운  3개의 Node ID 를 찾는다.
둘째. 그 노드들에게 동시적으로 FindNode RPC 콜을 날려준다. 
세째. 그 노드들은 가지고 있는 노드 정보 중에 타겟에 가까운 노드를 대답해준다.
네째. 그 노드들로 부터 응답받은 노드를 k-bucket에 담고, 다시 FindNode RPC 콜을 날려준다. 재귀적으로 시행한다.(보통 Kademlia 에서는 이런 행동을 하는 도중에 모든 노드들이 새로운 노드로 노드 테이블을 업데이트 하는데, 이더리움에서도 마찬가지일 것이다. 즉 live node 를 업데이트하는 과정이 find node 과정중에 자연스럽게 발생된다.)
다섯째. 더 이상 가까운 노드를 리턴하지 않으면 종료한다. 

이것 역시 이해가 안 간다면,  글 초기의 제주도 서귀포 출신 A군의 이야기를다시 생각해 보면 쉽게 이해 할 수 있으리라 본다.

연결 결정/유지

RLPx 는 노드간의 거리 기반으로 'potential' 노드 리스트를 제공한다. 이상적인 피어 숫자(디폴트 5) 에 준하여 잘 작동하는 접속만 유지하려 노력한다.이 전략은 모든 연결되었던 'close' 노드에 대해여 1개의 랜덤한 노드와 접속하는 것에 의해 실현된다. 연결을 결정하기 위한 자신만의 메타데이터와 전략을 사용 할 수 도 있다. 

주요 코드 (V4)

golang으로 작성된 코드는 (여기) 에 있으며,  cpp 등으로 만들어진 것도 있다. 노드디스커버리에 관련된 소스는 p2p 폴더안의 discover폴더가 버전4, discv5폴더가 버전5를 다루고 있다.p2p 폴더의 server.go에서 시작된 로직은 

- table.go 에는 전반적인 kademlia 알고리즘을 관리하고 있으며,
- node.go 에는 kademlia 에서 사용되는 노드 구조체를
- udp.go 에는 kademlia 알고리즘에서 UDP 프로토콜을 사용하여 외부로 통신하는 부분을 담고 있다.
즉 table.go 의 lookup 메소드는 내부에 findnode 메소드를 가지고 있는데 , findnode 메소드는 udp 콜을 한다. 
- database.go 에는 node 관련된 정보를 저장한 level.db 와의 통신부분을 담당하며
- ntp.go 에는 network time protocol 의 약자로 이더리움 피어들끼리 현재 시간을 맞추기 위한 로직을 담는다. 

부트스트랩 노드 정보 : (소스링크)


var MainnetBootnodes = []string{

	"enode://a979fb575495b8d6db44f750317d0f4622bf4c2aa3365d6af7c284339968eef29b69ad0dce72a4d8db5ebb4968de0e3bec910127f134779fbcb0cb6d3331163c@52.16.188.185:30303", // IE
	"enode://3f1d12044546b76342d59d4a05532c14b85aa669704bfe1f864fe079415aa2c02d743e03218e57a33fb94523adb54032871a6c51b2cc5514cb7c7e35b3ed0a99@13.93.211.84:30303",  // US-WEST
	"enode://78de8a0916848093c73790ead81d1928bec737d565119932b98c6b100d944b7a95e94f847f689fc723399d2e31129d182f7ef3863f2b4c820abbf3ab2722344d@191.235.84.50:30303", // BR
	"enode://158f8aab45f6d19c6cbf4a089c2670541a8da11978a2f90dbf6a502a4a3bab80d288afdbeb7ec0ef6d92de563767f3b1ea9e8e334ca711e9f8e2df5a0385e8e6@13.75.154.138:30303", // AU
	"enode://1118980bf48b0a3640bdba04e0fe78b1add18e1cd99bf22d53daac1fd9972ad650df52176e7c7d89d1114cfef2bc23a2959aa54998a46afcf7d91809f0855082@52.74.57.123:30303",  // SG

	// Ethereum Foundation Cpp Bootnodes
	"enode://979b7fa28feeb35a4741660a16076f1943202cb72b6af70d327f053e248bab9ba81760f39d0701ef1d8f89cc1fbd2cacba0710a12cd5314d5e0c9021aa3637f9@5.1.83.226:30303", // DE

}

노드디스커버리 관련 기본 구조체 : (소스링크)

type (
	ping struct {
		Version    uint
		From, To   rpcEndpoint
		Expiration uint64
		// Ignore additional fields (for forward compatibility).
		Rest []rlp.RawValue `rlp:"tail"`
	}

	// pong is the reply to ping.
	pong struct {
		// This field should mirror the UDP envelope address
		// of the ping packet, which provides a way to discover the
		// the external address (after NAT).
		To rpcEndpoint

		ReplyTok   []byte // This contains the hash of the ping packet.
		Expiration uint64 // Absolute timestamp at which the packet becomes invalid.
		// Ignore additional fields (for forward compatibility).
		Rest []rlp.RawValue `rlp:"tail"`
	}

	// findnode is a query for nodes close to the given target.
	findnode struct {
		Target     NodeID // doesn't need to be an actual public key
		Expiration uint64
		// Ignore additional fields (for forward compatibility).
		Rest []rlp.RawValue `rlp:"tail"`
	}

	// reply to findnode
	neighbors struct {
		Nodes      []rpcNode
		Expiration uint64
		// Ignore additional fields (for forward compatibility).
		Rest []rlp.RawValue `rlp:"tail"`
	}

	rpcNode struct {
		IP  net.IP // len 4 for IPv4 or 16 for IPv6
		UDP uint16 // for discovery protocol
		TCP uint16 // for RLPx protocol
		ID  NodeID
	}

	rpcEndpoint struct {
		IP  net.IP // len 4 for IPv4 or 16 for IPv6
		UDP uint16 // for discovery protocol
		TCP uint16 // for RLPx protocol
	}
)


FIND_NODE 함수:  (소스링크)

// findnode sends a findnode request to the given node and waits until
// the node has sent up to k neighbors.
func (t *udp) findnode(toid NodeID, toaddr *net.UDPAddr, target NodeID) ([]*Node, error) {
	nodes := make([]*Node, 0, bucketSize)
	nreceived := 0
	errc := t.pending(toid, neighborsPacket, func(r interface{}) bool {
		reply := r.(*neighbors)
		for _, rn := range reply.Nodes {
			nreceived++
			n, err := t.nodeFromRPC(toaddr, rn)
			if err != nil {
				log.Trace("Invalid neighbor node received", "ip", rn.IP, "addr", toaddr, "err", err)
				continue
			}
			nodes = append(nodes, n)
		}
		return nreceived >= bucketSize // bucketSize 는 16이고 table.go 파일에 하드코딩 되어 있다.
	})
	t.send(toaddr, findnodePacket, &findnode{
		Target:     target,
		Expiration: uint64(time.Now().Add(expiration).Unix()),
	})
	err := <-errc
	return nodes, err
}
// pending adds a reply callback to the pending reply queue.
// see the documentation of type pending for a detailed explanation.
func (t *udp) pending(id NodeID, ptype byte, callback func(interface{}) bool) <-chan error {
	ch := make(chan error, 1)
	p := &pending{from: id, ptype: ptype, callback: callback, errc: ch}
	select {
	case t.addpending <- p:
		// loop will handle it
	case <-t.closing:
		ch <- errClosed
	}
	return ch
}

FIND_NODE 패킷 처리 함수(Neighbors 데이터 공유):  (소스링크)

func (req *findnode) handle(t *udp, from *net.UDPAddr, fromID NodeID, mac []byte) error {
	if expired(req.Expiration) {
		return errExpired
	}
	if !t.db.hasBond(fromID) {
		return errUnknownNode
	}
	target := crypto.Keccak256Hash(req.Target[:])
	t.mutex.Lock()
	closest := t.closest(target, bucketSize).entries
	t.mutex.Unlock()

	p := neighbors{Expiration: uint64(time.Now().Add(expiration).Unix())}
	var sent bool
	// Send neighbors in chunks with at most maxNeighbors per packet
	// to stay below the 1280 byte limit.
	for _, n := range closest {
		if netutil.CheckRelayIP(from.IP, n.IP) == nil {
			p.Nodes = append(p.Nodes, nodeToRPC(n))
		}
		if len(p.Nodes) == maxNeighbors {
			t.send(from, neighborsPacket, &p)
			p.Nodes = p.Nodes[:0]
			sent = true
		}
	}
	if len(p.Nodes) > 0 || !sent {
		t.send(from, neighborsPacket, &p)  
	}
	return nil
}

UDP 객체의 send 메소드:  (소스링크)

func (t *udp) send(toaddr *net.UDPAddr, ptype byte, req packet) ([]byte, error) {
	packet, hash, err := encodePacket(t.priv, ptype, req)
	if err != nil {
		return hash, err
	}
	return hash, t.write(toaddr, req.name(), packet)
}

노드디스커버리(lookup):  (소스링크)

func (tab *Table) lookup(targetID NodeID, refreshIfEmpty bool) []*Node {
var (
target = crypto.Keccak256Hash(targetID[:])
asked = make(map[NodeID]bool)
seen = make(map[NodeID]bool)
reply = make(chan []*Node, alpha)
pendingQueries = 0
result *nodesByDistance
)
// don't query further if we hit ourself.
// unlikely to happen often in practice.
asked[tab.self.ID] = true

for {
tab.mutex.Lock()
// generate initial result set
result = tab.closest(target, bucketSize)
tab.mutex.Unlock()
if len(result.entries) > 0 || !refreshIfEmpty {
break
}
// The result set is empty, all nodes were dropped, refresh.
// We actually wait for the refresh to complete here. The very
// first query will hit this case and run the bootstrapping
// logic.
<-tab.refresh()
refreshIfEmpty = false
}

for {
// ask the alpha closest nodes that we haven't asked yet
for i := 0; i < len(result.entries) && pendingQueries < alpha; i++ {
n := result.entries[i]
if !asked[n.ID] {
asked[n.ID] = true
pendingQueries++
go func() {
// Find potential neighbors to bond with
r, err := tab.net.findnode(n.ID, n.addr(), targetID)
if err != nil {
// Bump the failure counter to detect and evacuate non-bonded entries
fails := tab.db.findFails(n.ID) + 1
tab.db.updateFindFails(n.ID, fails)
log.Trace("Bumping findnode failure counter", "id", n.ID, "failcount", fails)

if fails >= maxFindnodeFailures {
log.Trace("Too many findnode failures, dropping", "id", n.ID, "failcount", fails)
tab.delete(n)
}
}
reply <- tab.bondall(r)
}()
}
}
if pendingQueries == 0 {
// we have asked all closest nodes, stop the search
break
}
// wait for the next reply
for _, n := range <-reply {
if n != nil && !seen[n.ID] {
seen[n.ID] = true
result.push(n, bucketSize) // 새로 얻는 노드를 통해서 다시 찾기 위해 result 에 푸시
}
}
pendingQueries--
}
return result.entries
}

결론

이더리움의 node discovery 는  비트코인에 비해 적은 노력으로 최신의 상태를 잘 반영 할 수 있으며, 보다 더 신뢰가능하며,고른 분포를 가진 망을 구성 할 수 있으리라 보인다. 

구체적인 노드디스커버리의작동 과정은 일단 이더리움 자체에 하드코드 되거나 자신이 입력한 seed node 로 부터 시작되는데, table.go 파일에 구현되어 있는 무한loop가 doRefresh 함수를 호출하여  항상 새로운 이웃노드들을 자신의 테이블에 저장하고 상태체크를 하는데, 이웃노드 탐색에는 lookup 함수가 사용되어 FIND_NODE 프로토콜을 이용하여 재귀적으로 이웃노드들에 대한 정보를 찾는다. 이 과정에서 자신의 테이블에 자신과 가까운 녀석들은 많이 담게 되고, 자신도 다른 노드들의 탐색과정을 도와야 하기 때문에 랜덤한 Node ID로 부터의 탐색도 하여 테이블 (K-BUCKET)에 다양한 노드를 담을 수 있게 된다. 

이렇게 라우팅테이블에 노드들이 담기게 되며, 계속해서 살아있는 노드들로 업데이트 된다. 
실제 데이터를 주고 받는 WIRE 프로토콜에서는 이렇게 노드디스커버리에 의해 연결 가능한 노드들만으로 이루어진  라우팅테이블에 담긴 노드들중 랜덤하게 선택 하여 실제 스트림 통신을 하게 된다.

* 이더리움  RLPx 에 대한 구체적인 코드 분석(GO,C++,RUST) 및 비트코인/Hyperledger 와의 비교 포스트를 따로 계속 연재 할 예정이다.참고로 pydevp2p 라는 파이썬 기반의 오픈소스로 먼저 코드리딩을 하는게 훨씬 편할 것이다.
  • Node Discovery and Network Formation
  • Peer Preference Strategies
  • Peer Reputation
  • Multiple protocols
  • Encrypted handshake
  • Encrypted transport
  • Dynamically framed transport
  • Fair queuing

레퍼런스:

https://rchain.atlassian.net/wiki/spaces/CORE/pages/15564804/Ethereum+P2P+Node+Discovery+and+Routing
https://github.com/ethereum/devp2p/blob/master/discv4.md
https://github.com/ethereum/devp2p/blob/master/rlpx.md
https://eprint.iacr.org/2015/263.pdf



블록체인과 TPS

Transactions Per Second (TPS) 란 말 그대로 초당 트랜잭션 수를 의미하는데, 시중 대형은행의 TPS 는 보통 몇백에서 몇천건이라고 합니다. 글 본론에서 소개해드릴 자바기반의 LMAX 아키텍쳐에서 사용한 DISRUPTOR 패턴은 초당 600백만 트랜잭션을 처리 할 수 있다고 하는데요. (실..실화냐?)

* DISRUPTOR 패턴을 한마디로 축약하면  "싱글 쓰레드" 의 힘!! 입니다.

TPS 는 보통 "- 외부에서 데이터를 받고 - 그 데이터를 처리하고 - 처리된 데이터를 외부로 내보내고" 를 한 싸이클로 보고 계산을 하게 되는데, 그림에서 보다시피, 외부에서 데이터를 받는 네트워킹 상황에 따라서도 영향을 받을 수 있으며, 내부의 로직을 처리하는 시간에 따라서도 영향을 받게 됩니다.  1세대 블록체인 기술인 비트코인 같이 모든 노드가 네트워킹에 참여하여 동일한 역할을 수행하는 경우, 그 부분에서의 제약이 작지 않으리라는 것을 추측 할 수 있을 것입니다.

또한 블록체인에서는 "신뢰의 속도"라는 개념이 TPS에 추가됩니다. 따라서 기존 메세지큐(MQ) 들의 TPS 를 계산하는 방식을 그대로 적용할 수는 없는 노릇이기도 합니다.


비트코인

비트코인 경우 1MB 의 블록용량한도와 10분의 블록 생성을 근거로, 1 트랜잭션을 계산해보면 약 7TPS 가 채 안나오게 됩니다. 네트워킹 능력을 떠나서 애초에 로직상에서 처리 할 수 있는 한도를 정해놨기 때문에 추가 성능향상은 트랜잭션 크기를 줄이거나, 블록생성 속도를 빠르게 할 수 밖에 없지요. (이런 논의는 계속되고 있습니다.) 즉 컴퓨터 공학적인 어떤 트릭이나 기술로만은 해결하기 힘든 문제입니다. (사이드 체인은 논외)

이더리움

이더리움 경우는 블록생성 주기가 대략 12초이며, TPS 는 10~30정도로 보고 있습니다. 일단 이더리움은 비트코인과 다르게 블록사이즈의 limit 와 트랜잭션 사이즈가 정해져 있지 않으며, 비트코인보다는 채굴속도도 비약적으로 빠르지만 TPS 처리가 쩌리인 이유는 블록당 사용할 수 있는 가스가 정해져 있기 때문입니다. 가스라는 것은 이더리움상에서의 코인을 좀 더 유연하게 처리하기 위한 대체재인데, 트랜잭션 비용을 처리 할 때 매개체로 사용됩니다. 그 가스량이 정해져 있다는 것은 블록사이즈의 크기가 정해져 있다는 것과 비슷한 제약을 가져오는 것이지요. (DDOS 등 외부 침입자의 무차별적인 트랜잭션 전송을 막기 위한 방편으로 트랜잭션별,블록별가스리밋이 생김) 

이더리움의 차세대 합의 메커니즘인 캐스퍼에서는 일단 블록생성 주기는 엄청 짧아 질 것이며, (신뢰의속도 증가) 기타 다양한 (샤딩 등) 기술들의 도입으로 TPS 는 훨씬 빨라지리라 예상되고 있습니다. 

하이퍼레저 패브릭

프라이빗 블록체인인 이 놈은 15노드로 10만 TPS 를 목표로 합니다.

스팀/EOS

이더리움의 강력한 라이벌인 EOS 는 백서에서 밝히길,싱글 쓰레드에서 1만 TPS를, 멀티쓰레드에서 100만 목표로 하고 있습니다. EOS 를 만든 댄라이머는 비트쉐어와 스팀도 만든 사람인데, 이것들은 모두 DPOS (간접민주주의 방식)라는 합의메커니즘을 사용하며, 플랫폼 내부에 있는 그래핀이라는 코어의 트랜잭션 처리 기술이 Distruptor 패턴에서 영감을 받았습니다. 어떤 엄청난 신뢰의 산물을(신뢰비용)을 만들기 위해 많은 시간을 쏟아 붙는 것도 아니고, 전국민이 투표하는것도 아니고, 몇몇 대표자들끼리 투표하는데 얼마 걸리겠습니까? 

(참고로 스팀/EOS 는 트랜잭션 비용도 무료(?)입니다. 소액결제부분에서 이더리움이 너무나 큰 약점으로 생각되는 부분이죠. 물론 엄청나게 뛰어나보이는 기술/철학 뒤의 약점도 많습니다. 참고. 트레이드오프가 있지 항상 참인 기술은 없으니까요, 블록체인 플랫폼간의 트랜잭션 비용 전쟁은 사용자들이 가장 흥미롭게 지켜 볼 만한 요소라고 생각합니다. 여담으로 저는 현재 이더리움의 사이드체인 기술들, 예를들어 loom network 와 스팀의 SMT에 굉장히 매력을 느끼며 저울질 중에 있습니다)

(https://steemit.com/coinkorea/@dev1by0/3qa1pb-eos 발췌)


이제 블록체인 얘기는 그만하고, EOS가 영향받은 LMAX Architecture 와 Distruptor 패턴에 대해서 살짝 알아 보겠습니다. 일단 이 패턴은 일반적으로 사용될 수 있는 기술이고, 오픈소스로 공개되어 있기 때문에, 여러분의 상황에 맞다면 가져다 쓸만하다고 볼 수 있습니다. 물론 그 상황이 초당 몇백,몇천 정도 TPS 수준으로 충분하다면 굳이 바꿀 필요는 없겠지요.이 놈은 초당 몇백만TPS를 처리하는 녀석이니까요. 


LMAX Architecture 와 Distruptor 패턴

제가 C++로 유체역학 솔루션을 만들던 (즉 대규모 트랜잭션처리에 대해선 무지) 2011년에 자바진영에서 엄청난 물건이 나왔습니다. 스타워즈에 나오는 광선총의 이름과 같은 Distruptor 패턴인데, smalllake 님의 블로그에 소개된 내용을 보면

"Java로 개발된 프로그램에 주는 Duke’s Choice Award를 2011년에 수상한 Distruptor는 현재 영국 LMAX에서 사용하고 있습니다. LMAX는 FX(외환거래)를 하는 곳입니다. 겉으로 보면 증권사같습니다만 FX는 중앙거래소가 없기때문에 LMAX자체가 거래소라고 할 수 있다고 합니다. 고객의 호가주문을 받아서 매매체결하는 부분으로 연결시켜주기 위한 라이브러리가 Disruptor입니다. Inter-Thread Library이며 고성능지지연(Low Latency)을 가능하도록 합니다. 그런데 LMAX는 이를 오픈소스로 공개하였습니다." 

라고 적혀져 있는데요. 해당 소스는 여기를 참고하시면 됩니다 (https://github.com/LMAX-Exchange/disruptor)
처음은 자바로 짜여져있으나, 모든 패턴이 그러하듯이 다른 언어로도 충분히 구현이 가능합니다. 이 패턴에는 어떤 외부 라이브러리가 필요 없으며, 자바의 고유 사용패턴을 함께 사용할 필요도 없습니다.

이 패턴을 개발하기전에 액터패턴 같은 것을 처음 시도하였지만, 쓰레드 사용에 대한 오류와 복잡도에 대한 부분은 줄였지만, 역시 액터사이에 메세지를 전달하기 위해 사용되는 큐에 대한 지연 부분이 문제가 되었다고 합니다. 즉 로직에서 지연보다 큐에서 블럭되어 있는 시간이 훨씬 길었다는 것이지요. 

이때 눈여겨 본 부분은 바로, "캐시" 이며, "캐시" 를 적극적으로 사용하기 위한 아이디어로 Distruptor는 탄생되었습니다.이것은 아무리 소프트웨어,특정 언어라고 말해도, 기본적인 하드웨어에 대한 관심도 중요하다는 교훈(mechanical-sympathy)을 줍니다. 


캐시

컴퓨터 내부의 각 장치들 간의 속도를 보면

보시다시피 , CPU는 1ns 즉 10억분의 1초의 속도를 자랑하지만,  HDD로 갈수록 점점 엄청 느려지는 것을 알 수 있습니다.

멀티코어 CPU라고 모든 캐시와 메모리를 각자 사용하는 것이아니라, 메모리는 물론 캐쉬도 공유합니다.

보통 우리가 (제가) 어떤 메세지를 전달하는 구조를 생각할때 가장 먼저 떠올리는 것은 생산자-소비자 패턴에 중간의 매개체로 언어에서 제공해주는 쓰레드 안전한 큐를 사용하는 생각을 했을 것인데요. 이런 큐는 항상 앞이나 뒤에서 머무르는 시간이 대부분일테고 (밸런스가 딱 맞기 힘들죠), 그리고 생산자들과 소비자들 쓰레드간에 Lock을 반드시 걸어줘야 할 겁니다. 이 Lock은 생각보다 훨씬 더 속도에 악영향을 미치는데요. 

락이 걸리는 순간 컨텍스트 스위칭이 커널에서 발생하며, 프로세서는 캐쉬안에 저장되있는 데이터를 잃어버리기 쉬운 환경이 조성됩니다. 캐시를 보다 잘 보존하기 위해서는 단순합니다. 그저 하나의 코어만 쓰기를 하면 됩니다. (다중읽기는 괜찮지만요) 만약 두개의 쓰레드가 각기 다른 값에 쓰기를 할지라도 서로 다른 캐시라인을 무효화 할 수 있게 됩니다. 이 말은 큐에서 하나는 head 에서 작업하고 하나는 tail에서 작업한다고 할 경우에도 캐시에 문제가 생길 수 있다는 의미입니다.


링버퍼 와 Distruptor 

그럼 Distruptor에서는 락을 걸지 않고 어떻게 쓰레드간에 데이터를 주고 받을 수있을까요? 그 키는 링버퍼에 있습니다.

보다시피, 버퍼의 개수는 정해져있으며, 마지막 슬롯이라는 개념이 없습니다. 위 그림상에서 7번이 끝이아니라, 0번버퍼를 다시 8번으로 그래도 재활용(재할당없이)하기 때문입니다. 즉 버퍼를 가르키는 포인터에 대한 인덱스는 버퍼사이즈만큼으로 정해져 있지만, 시퀀스는 무한히 (64 bit signed numbers: 초당 백만건 처리해도 3십만년) 생성됩니다. 

하나의 생산자와 하나의 소비자를 대상으로 할 경우 링버퍼에서 일어나는 일은 매우 단순한데요. 생산자와 소비자는 각각 자신의 어디쯤에 있는지만 서로 확인 할 수 있으면, 그 범위 내에서 작업을 하면 되기 때문입니다. 

예를들어 생성자가 5번까지 입력했어. 라고 하면 (cursor 위치가 5가 됨) 소비자는 5까지 읽어가면 됩니다. 
반대로 생성자는 소비자가 읽은 곳을 넘어가서 입력하면 안되겠지요. (waiting 이 이루어 집니다) 


생산자가 여러개 일 경우는 좀 특별하다. 생산자A가 10번 슬롯을 먼저 처리했다고해서, 그 곳까지 소비자가 읽을 순 없게 해야한다. 이유는 또다른 생산자B는 아직 8번슬롯을 처리하지 못했을 수도 있기 때문이다. 따라서 생산자B가 완료 할 때까지 기다려서 생산자 A는 확정 처리 해야한다. 

소비자의 경우는 일단 WaitFor 하다가, 이전에 자기가 처리 했던 슬롯위치 +1 의 위치의 값을 가져오는데, 생산자가 입력을 했는지도 확인을 해서 ㅇㅋ 면 값을 가져와서 처리한다. 또한 생산자의 속도가 빨라서 거리가 많이 벌어졌다면, 소비자는 범위를 크게 잡아서 데이터를 가져 갈 수도 있기 때문에, (batching effect) 밸런싱 맞추는것도 쉬워집니다. 주요 단점으로는 소비자가 여럿일 때 하나의 소비자에서 지체현상이 일어난다면, 해당 슬롯이 막혀서 생산자는 더이상 추가를 못하게 되고, 따라서 소비자들도 정체 될 것입니다.  생산자,소비자가 여러개 일 경우 등 좀 더 구체적인 내용에 대한 설명은 다음 세 글을 참고하세요 

-> Trish’s post codeaholics's post, 쭌안아빠님 블로그

또한 리팩토링의 저자이자 패턴계의 스승이신 마틴파울러는 2011년 그의 글에서 LMAX Architecture 이 왜 만들어졌는지에 대한 전반에 대해 말하고 있으며, 블록체인(bitshare)에서 사용된 LMAX Architecture 에 대한 글 링크를 소개합니다.

또한 리팩토링의 저자이자 패턴계의 스승이신 마틴파울러는 2011년 그의 글에서 LMAX Architecture 이 왜 만들어졌는지에 대한 전반에 대해 말하고 있으며, 블록체인(bitshare)에서 사용된 LMAX Architecture 에 대한 글 링크를 소개합니다.


Distruptor 패턴 과 블록체인

마지막으로 다시 블록체인으로 돌아와 보죠.

먼저 EOS,스팀에서  LMAX Architecture 를 통해 얻은 교훈은 아래와 같습니다.

ㅡ 모든것은 메모리에 유지시키자.

ㅡ 코어 비지니스 로직은 무조건 싱글 쓰레드에서 처리한다.

ㅡ 암호화에 관련된 조작들은 (해싱,서명) 코어 비지니스 로직 외부로 꺼낸다.

ㅡ 상태의존적인 검증과 상태독립적인 검증을 나눈다

ㅡ 데이터 모델로 부터 만들어진 객체를 사용한다.

그리고 직접 소스를 살펴보며 적용된 것을 개발자 시각으로 생각해 볼것은 아래와 같습니다.

ㅡ 왜 굳이?

ㅡ 어떻게 구현 해 있나?

ㅡ 개선방안은?

이 있겠으며, 

좀 더 넓은 시야에서는 

ㅡ 비트코인,이더리움에서는 신뢰의 속도 때문에 Distrupt 패턴이 무의미 한데, 스팀,EOS 는 삐른 대신 대신 신뢰의 속도를 포기 한건지?

ㅡ 어떻게 보면 얄팍하기 까지 보이는 EOS,STEEM 의 신뢰의 속도 처리 방안에 비해 이더리움은 앞으로 어떻게 묵직히 처리 할 것인지.

를 지켜보는 재미가 있다고 볼 수 있습니다.


* 모든 기술에 은탄환은 없으니, Distruptor 패턴도 자신의 상황에 맞춰서 잘 적용하는것이 중요 할 것입니다.
* 이 포스트의 최종목적이 EOS 에서 사용된 LMAX Architecture 해부 인데, 어느정도 분석이 되면 따로 글을 파겠습니다.


레퍼런스:

https://medium.com/corda/transactions-per-second-tps-de3fb55d60e3
http://blog.codeaholics.org/2011/the-disruptor-lock-free-publishing/
https://martinfowler.com/articles/lmax.html
http://goodjoon.tistory.com/254
http://www.smallake.kr/?p=2254
https://www.slideshare.net/trishagee/introduction-to-the-disruptor?next_slideshow=1
http://mechanitis.blogspot.kr/2011/07/dissecting-disruptor-writing-to-ring.html

 

 

블럭,논블럭,동기,비동기 이야기  

 

블록,논블럭,동기,비동기를 구분하는 것에 대한 글들이 많이 있는데, 어렵게 풀어내는 거 같아서 나름 간단하고 분명하게 구분해 보는 글을 작성 해 본다. 근데 함정이 있는데 분명하게 정답을 말해 준다는게  아니다. 분명하게 정답이 없으며, 불분명하다고 말해주려는 것이다. ㅎㅎ  면접시나 시험지에 적을 정확한 정답을 몰라서 혹시 불안해 하시는 분이 있다면 이 글을 읽고 안심하셔도 될 것이다.(뭐 시험관이 잘못알고 있는것 까지 책임지진 못하겠다. 과감히 논쟁하시라~~ㅎ) 구체적으로 블럭/논블럭에 대한 구분은 비교적 명확하다. 동기/비동기로 넘어가면 말하는 상황에 따라서 조금 달라지기 시작한다. 이제 조합하기 시작하면 문제가 발생하기 시작한다. 이 글에서는 이것의 구분에 대한 설명을 해드릴 것이지만, 구분을 굳이 왜 해야하는가? 라는 의문이 생길 수도 있을 것이다. 그렇다면 내 글의 의도가 먹힌 것이다. 사실 이것은 구분을 명확히 해야하는 문제라기 보단, 이런식으로 시스템이 작동 하기도 하는 구나 라는 "감" 을 잡으면 되는 문제이다. 

여기서 내가 내리는 정의 또한 "정답" 이 아니다. 애초에 정답이 없는 문제라고 생각하고 유연하게 바라보자. 즉 1+1=2 같은 종류의 문제라거나, 자바에서 class 의 정의는? 같은 문제와는 다르다. 디자인패턴 같은 느낌이다. "의도"는 있지만 "구현"은 제각각인.. 

처음에는 각각의 정의를 내려보고, 그 담엔 우체국을 예로 들어서 이야기 식으로 구분을 해 보며, 마지막으로는 코드를 통한 예를 통해 그 "감"을 잡아내는 수확을 얻도록 하자.

 

@ 다 읽기 귀찮고 감 만잡으려면 3번 우체국이야기는 꼭 읽자

@ 애초에는 I/O 과 연관되어서 정의내리는 것이었는데, I/O 상관없는 비동기가 자주 사용되면서 더 희미해졌다.

 

 

1. 개별 정의


블럭/논블럭 

- 블럭/논블럭는 함수호출에서의 이야기이다.(기술적으로 명확히 구분된다.) 
- A 라는 함수를 호출했을때, A라는 함수를 호출 했을 때 기대하는 행위를 모두 끝마칠때까지 기다렸다가 리턴되면, 이것은 블로킹 되었다고 한다.
- A 라는 함수를 호출 했는데, A라는 함수를 호출 했을 때 기대하는 어떤 행위를  요청 하고 "바로" 리턴되면 이것은 논블럭킹 되었다고 한다.

동기/비동기

- 동기/비동기는 행위에 대한 이야기이다.
- 여기서 "행위"는 단순히 서로 다른 쓰레드 or 프로세스 or  서버에서 일어나는 일련의 동작들 이라고 치환해서
  생각하면 이해하기는 쉽다. 
- 동시성(concurrent) 의 문제이지 병행성(parallel ) 과는 무관하다. (병행성에 대한 의식은 잠시 잊어버리자) 

- A 라는 행위와 B 라는 별개의 행위가 있다고 하자. A 라는 행위와 B 라는 행위가 동시(or 순차적이지 않다면)에 실행되고 있으면 비동기라고 한다. 여기서 제약이 하나 있는데 A,B 행위 사이에는 인과관계가 있어야 한다. 즉 웹서버를 예로 들어서 멀티쓰레드로 각각 A와B가 다른 클라이언트와 작업 할 때 둘은 동시에 작업하고 있지만, 둘의 인과관계는 없지 않나? 이땐 비동기라고 볼 수 없다. A라는 행위의 결과를 B라는 행위에서 언젠간 이용하게 될 때 비동기라고 본다. 

- A라는 행위와 B라는 행위가 순차적으로 작동한다면 동기라고 한다. 
- 동기적 행동에는 하나가 더 있다. A라는 행위가 별개의 것이 아니라, B라는 행위를 관찰하는 행위라면 이것이 동시에 일어나더라도 동기이다. 기술적으로 말해서 A라는 쓰레드와 B라는 쓰레드가 따로 돌아 간다고 해도, 어떤 하나의 행위가 다른 행위에 밀착되어 있다면 두 행위가 다른 쓰레드에서 벌어지더라도 동기란 말이다. 관찰하는 행위라는 말자체가 정확한 기술적 구분이 되는게 아니기 때문에 추상적이라는 표현을 사용한 것이며, 이 글의 가장 불분명한 요소 중 하나이니 잘 기억해 두도록하자. 이해가 안가면 다음의 우체국 예제와 실제 코드예제를 통해 이해할 수 있을 것이다.

사실 여기까지만 읽으셔도 된다. "굳이" 조합까지는 생각 할 필요는 없다. 조합은 내 개인적 상상력의 산물이다. 

2.조합 정의  
 
블럭/논블럭과 동기/비동기를 조합해서 상상을 해보자. 


블럭/동기 

A가 실행되다가 B라는 일을 수행하는 함수를 호출해서 B를 시작한다. B라는 일이 끝나면 함수를 리턴한다. A와 B는 순차적으로 진행되기 때문에 동기이며,  B라는 일을 하는 함수를 호출하고 그 일이 끝나고 나서야 리턴되므로 블럭된 것이다. 따라서 블럭/동기 

 

블럭/비동기 

어떻게 블럭되었는데 A,B라는 일이 동시에 일어나는가? 설명을 들어보고 이런 경우를 말하는구나라는 "감"을 잡아보자.

일단 A는 B라는 일을 시킨다. 그리고 바로 리턴하고 (여기서는 논블럭)  B는 일을 시작하고, A도 자신의 일을 한다. A는 중간에 B라는 일이 하는 중간 결과를 보고 받아서 처리해야한다. A는 B에게 요청을 해서 중간결과를 기다린다(블록), 요청의 결과를 받고 나서 그 결과를 이용해서 A는 자신의 일을 처리한다. 동시에 B 는 또 자신의 일을 동시에 한다. (비동기) A는 다시 B에게 중간결과를 요청해서 기다린다 (블록) , 요청의 결과를 받고 A는 자신의 일을 , B는 자신의 일을 한다. 반복된다.

이 글을 읽고, 사실 갸우뚱 해야한다. 중간에 블록되는 동안에는 "동기" 라고 말 할 수 있기 때문이다. 즉 어느 한 순간에 대해 해석하자면 틀릴 수도 있는것이다. 즉 처음부터 말해왔듯이 "정답"이 존재하지 않는다. 다만 이런 패턴들이 분명히 사용되고 있구나라고 감을 잡는게 목적이다.

 

논블럭/동기 

이것이 예도 위의 블럭/비동기와 비슷한데 조금 다른 늬앙스에 대해서 "감"을 잡아보자.

이것도 역시 A는 B라는 일을 시킨다. 바로 리턴한다. (논블럭)  B는 일을 시작하는데, A는 자신의 일을 하지 않는다. A의 하는 일이란 그저 B가 하는일을 확인하는 것이다. B가 결과 보고(중간 보고가 아니다) 를 했는지를 확인하는 함수를 호출하고 ,바로 리턴한다 (논블럭) 즉 결과 보고를 받을 때 까지 기다리는게 아니라, 결과 보고가 나왔는지 확인하고 바로 리턴하는 것이다.  이 짓을 계속한다. 즉 함수를 계속 논블럭으로 호출되긴 하나, A는 그저 B를 염탐할 뿐이다. 이 상태를 말한다. 그냥 염탐하지 말고 B가 일을 모두 끝마치고 리턴되길 기다리지 ;;; (그냥 블럭/동기로 하는게 나은 상황이 연출된다) 
 
이후에 B가 결과보고를 하면,B는 자신의 일이 끝난 것이고 A는 이제서야 자신의 일을 처리하게 된다. 즉 순차적이라는 말이다. 따라서 동기~
 

논블럭/비동기 

간단하다. A는 B의 일을 시작시키고 바로 리턴한다 (논블럭) 그리고 A와B는 각자 자신의 일을 한다 (비동기) 

 

 

2. 실행활에서 일어나는 우체국 이야기로 풀어보자.

 

블럭/동기 

우체국에 배달 트럭들이 줄을 서 있다. 우체국에 들어오는 물품들을 싣기 위해서인데,
 
- 1번 트럭이 우체국에 내 것들을 가져와주세요 요청하고 기다린다. (블럭)

- 우체국은 1번 트럭에게 주기 위한 물건들을 찾아서 싣기 시작한다. 

- 2번트럭은 1번트럭에 물건이 다 싣기를 기다린다. (블럭)

- 3번 트럭도 기다린다. (블럭)

- 1번트럭이 물건을 싣고 떠나면, 우체국은 이제 2번 트럭의 물건을 찾아서 싣는다. (동기) 

모든 일들이 순차적으로 일어 난다 (동기) 

 

블럭/비동기 

우체국에 가서 내가 필요한 물품은 무엇이라고 접수원에게 말을 하고 집으로 돌아온다.

- 우체국은 물품을 준비하고, 나는 집에서 집안 청소를 한다. (비동기)

- 우체국에 전화 해서 접수원과 통화한다. 물품이 준비되었냐고 물어본다. 접수원은 준비될 때 까지 기다리라고 한다. 나는 하염없이 기다린다 (블럭)

- 접수원이 준비됬다고 말한다. 나는 트럭을 가지고 우체국으로 가서 물건을 싣고 온다.

- 우체국은 자신의 일을 하고, 나는 싣고 온 물건을 배달한다 (비동기) 

중간에 블럭되는 지점이 있지만, 그 이전과 이후에는 각자 자신의 일을 한다. 

 

논블럭/동기 

우체국에 가서 내가 필요한 물품은 무엇이라고 접수원에게 말을 하고 집으로 돌아온다.

- 우체국은 물품을 준비하고, 나는 전화기를 붙잡는다.

- 우체국에 전화 해서 접수원과 통화한다. 물품이 준비되었냐고 물어본다. 접수원은 안됬다고 말한다. 나는 전화를 바로 끊는다. (논블럭)  

- 전화를 끊고, 집안 청소를 하는게 아니라, 다시 우체국에 전화한다. 안됬다고 하면 바로 끊는다 (논블럭)

- 계속 반복적으로 전화한다 (논블럭이며, 나는 내 일을 하는게 아니라 우체국의 일에 매달리고 있으므로 동기) 

- 이번 전화에는 접수원이 준비됬다고 말한다. 나는 트럭을 가지고 우체국으로 가서 물건을 싣고 온다.

- 나는 싣고 온 물건을 배달한다.

중간 중간 논블럭으로 전화를 바로 끊지만, 끊고 나서 바로 또 전화를 하므로 동기

 

* 이 경우에 내가 배달하는 동안에는 현실과 좀 다르지만 우체국은 쉰다고 생각 해야한다.  (동기

 

논블럭/비동기 

우체국에 가서 내가 필요한 물품은 무엇이라고 접수원에게 말을 하고 트럭을 놓고 집에 온다. (논블럭)
트럭(버퍼) 크기가 크다면 우체국에서 많이 채워 줄 것이다. (하지만 좀 더 시간이 걸리겠지) 

- 우체국은 물품을 준비하고, 나는 집에 와서 내일 을 한다 (비동기)

- 전화 따위는 하지 않는다. 우체국에서 알아서 트럭에 짐을 채워서 나에게 트럭이 준비됬으면 연락 할 것이기 때문이다. 

- 트럭이 가득 찼다고 연락이 왔다. 나는 트럭을 가지고서 배달을 시작하고 우체국은 자신의 일을 한다.

이것이 논블럭/비동기이다. 완전 효율적이지 않는가? 

하지만 이것도 병목지점이 있다. 어디일까? 
그렇다. 이 배달기사는 트럭이 한대 뿐이다. 
트럭이 한대 뿐이기 때문에, 배달하는 동안에는 우체국에서 또 다른 짐을 싣지 못한다.
 
어떻게 해결 할까? 간단하다. 트럭을 2개 만드는 것이다 (기술적으로 버퍼를 2개)
그러면 한대는 배달하는 동안에 우체국에 다른 한대를 맡겨 놓는 것이다.
이렇게 되면 배달일 끝날 쯤에는 우체국에 가있는 트럭은 가득 차 있을 것이고, 나는 연속적으로 배달을 할 수 있어서 돈을 많이 벌 수 있을 것이다.
 
여기서 끝이 아니다 병목이 또 하나 있다. 이번엔 무엇인가?
그렇다 배달기사가 하나라는 것이다. 
 
우체국에서 또 다른 트럭이 벌써 가득 차 있다고 연락이 왔지만, 배달중이라 그것을 처리 할 수가 없다. 
이때 어떻게 해야하나? 
그렇다 배달알바를 구하면 된다. 한대의 트럭이 준비되면 그 트럭이 짐을 3등분해서 배달알바 3명에게 나눠준다. 또 다른 트럭이 준비되면 , 배달알바가 끝난 알바생에게 나눠주거나 또 다른 알바생에게 나눠주면 된다.
 
일의 크기에 따라서 알바생을 늘리면 되는 것이다. 이 알바생이 소프트웨어에서 무엇일까?
그렇다~~ 멀티쓰레드이다.
 
비동기 / 싱글쓰레드로 짧게 짧게 일하는 곳 (Node 비동기 서버에서 간단한 리턴만 서비스 하는 곳)에서는 멀티 쓰레드를 굳이 도입하지 않아도 효율적이지만, 백단에서 먼가 해야 할 것이 많다면 (CPU intensive) 이렇게 멀티쓰레드를 추가 해주면 성능이 대폭 올라 갈 것이다.
 

3. 실제 코드 예제로 풀어보자.

 

블럭되어 동기식으로 일처리 - javascript

const fs = require('fs'); 
const data = fs.readFileSync('/file.md');

파일 다 읽을 때 까지 함수가 멈춰져 있으며, (블럭) 다른일도 못한다 (동기)

논블럭되어서 비동기식으로 일처리 - javascript

const fs = require('fs'); 
fs.readFile('/file.md', (err, data) => {  
	// readFile 호출해 놓고 바로 리턴한다.    
	if (err) throw err;  // 하지만 이 일에 대한 인과관계 장치를 마련해 둔다.
 });  ... 다른일을 한다 ...
파일 읽으라고 명령해 두고 바로 리턴(논블럭) 바로 다른 일을 한다 (비동기) 


논블럭인데 비동기는 아니다  - golang
func start_server() {     
	 l, err := net.Listen(CONN_TYPE, CONN_HOST+":"+CONN_PORT)    
	 defer l.Close()     
	 for {        
		 conn, err := l.Accept()         
		if err != nil {            
			log.Print(err)             
			continue         
		}    
	 go handle_client(conn)    
 } 
}
go 루틴으로 (go handle_client) 개별 처리하게 만들고 바로 리턴해서 논블럭이라 할 수 있다. 하지만 go 루틴으로 분기된 것들은 멀티쓰레드로 동시에 일은 하지만 인과관계 부족으로 동기/비동기와 무관하다고 볼 수 있다.

논블럭이면서 비동기  - golang

아래의 예처럼 go 루틴으로 분기시킨 후에 go 채널로써 상호작용(인과관계)를 발생시키는 경우에는 비동기식이라 할 수 있을 것이다.
 sigs := make(chan os.Signal, 1)  
 signal.Notify(sigs)   
go func() {    
    s := <-sigs     
    log.Printf("RECEIVED SIGNAL: %s",s)    
    AppCleanup()    
    os.Exit(1)  
}()
 
 
논블럭/동기  - Scala 
 
val myFuture: Future[String] = Future {      
	val f = Source.fromFile("build.sbt")         
    try 
    	f.getLines.mkString("\n") 
    finally
    	f.close()     
}         
    if myFuture.isCompleted {              ....     }        
    Thread.sleep(100)      
    if myFuture.isCompleted {              ...     }      
    Thread.sleep(100)         
    if myFuture.isCompleted {              ...     }
파일을 읽어 주세요 라고 (논블럭) 으로 일을 시키고나서, 자신의 일은 안하고 100초에 한번씩 계속 눈치보면서 확인함 (동기)
물론 실제 저런식으로 무식하게 코딩을 하진 않는다. 
 
// 실제로는 아래처럼 onComplete 에 콜백을 등록해 준다.   
// 더 콜백들을 편하게 조작하기 위해서, 많은 언어에서 지원하는 async/await 또한 지원한다.  
val file = Future { Source.fromFile(".gitignore-SAMPLE").getLines.mkString("\n") }  
file onComplete { 
	case Success(text) => log(text)     
	case Failure(t) => log(s"Failed due to $t")  
 }  이렇게 사용하면 논블럭/비동기라 할만하다.
 

블럭/비동기  - JAVA (NIO)

Selector selector = Selector.open();    
ServerSocketChannel mySocket = ServerSocketChannel.open(); 
InetSocketAddress myAddr = new InetSocketAddress("localhost", 1111); 
mySocket.bind(myAddr);  
mySocket.configureBlocking(false);  
int ops = mySocket.validOps(); 
SelectionKey selectKy = mySocket.register(selector, ops, null);
 while (true) {      
 	selector.select();     
 	Set<SelectionKey> myKeys = selector.selectedKeys();     
 	Iterator<SelectionKey> myIterator = myKeys.iterator();      
 	while (myIterator.hasNext()) {        
 		SelectionKey myKey = myIterator.next();          
 		if (myKey.isReadable()) {                         
 		SocketChannel myClient = (SocketChannel) myKey.channel();             
 		ByteBuffer myBuffer = ByteBuffer.allocate(256);             
		 myClient.read(myBuffer);            
		 String result = new String(myBuffer.array()).trim();        
	 }         
 	crunchifyIterator.remove();     
 } 
 }

 

파일(or 소켓입력)을 읽어주세요 라고 부탁하고 나서, 자신의 일을 하다가 파일이 읽혀졌는지 selector.select() 를 통해서 무한 대기 즉 우체국에 전화 걸어서 접수원이 기다리라고 해서 대기중 (블럭).
 
selector.select() 가 리턴을 함 (접수원이 짐이 가득 실어 졌다고 말함) 
그러면 이제 우체국에가서 짐을 실음.(사실 짐을 싣는 과정도 블러킹이지만 멀티쓰레드를 이용해서 병목을 줄일 순 있음)  짐을 싣는 도중에 우체국은 다른 사람 짐을 처리하기 위해 자기 할 일 함.(비동기) 

 

논블럭/비동기 - C++ (IOCP)

while (! isStop())   {     
	위의 이야기에서는 한명의 트럭기사가 처리했지만, 4명의 오너배달부들이 트럭을 감시 할 수도 있다.      
	그럼 한명이 병목되더라도 나머지 기사들이 들어온 짐들을 처리 할 수 있을 것이다.     
	if (GetQueuedCompletionStatus(m_hIOCP, &dwTrans, &pKey, (LPOVERLAPPED*)&pOV, 64))    
	{         
		if (pOV)         
		{             
			MySession* sess = pOV->m_sess;                 ....             
			if (pOV == &sess->m_recv1) //트럭1(버퍼1)을 다 실었다고 우체국으로 부터 연락옴             
			{                
				bool error = false;                
				sess->m_recv1.m_size = dwTrans; // 받은 데이터 사이즈                  
				sess->m_recv2.Reset(); //recv1 버퍼를 처리할것이고, recv2 버퍼는 OS한테 넘긴다.               
				//트럭을 가져온다(기술적으로는 os버퍼에서 응용버퍼로 데이터를 이동한다)                   
				if (! ReadFile((HANDLE)sess->m_sock, (LPVOID)sess->m_recv2.m_data.c_str(), (DWORD)sess->m_recv2.m_data.size(), &dwTrans, &sess->m_recv2))                 
				{                     
					if (GetLastError() != ERROR_IO_PENDING)                         
					error = true;                
				}                  
			OnData(*sess, sess->m_recv1);// 트럭에 실린 짐을 처리하기 시작한다.                 
		if (error)                    
			OnClose(*sess);             
		}             
		else if (pOV == &sess->m_recv2)             
		{                 
				//우체국에서 트럭2에 짐을 다 실었을 경우에 처리한다.                                 
				//트럭만 바뀌었을 뿐이지 위와 동일             
		}         
		}     
	} 
}    
bool MyServer::onData(MySession& sess, MyPacket& data)
{     
	std::string cmd, dat;    
	while (recvPacket(sess, cmd, dat))    
	{        
		//우체국으로 부터 받은 트럭으로 부터 배달을 시작함                
		//여기서 멀티쓰레드 사용하면 (알바배달부를 더 고용하면) 더 효율적이게 됨                
		//물론 너무 많이 만들어도 곤란하다.          
	}    
	return false;
}  
// 이 코드는 트럭에 실린 짐을 확인하는 코드이다.  
// 즉 트럭에 실린 짐이 김연아에게 갈 짐이 맞는지 확인하는 것이다.  
// 트럭에 실린 짐이 김연아에게 갈 짐 중에서 50% 밖에 실리지 않았다면  
// 김연아의 짐이 100% 될 때까지 트럭을 다시 우체국에 보내고 처리하지 않는다.  
// 기술적으로는 "syn:: ~~~ ::end" 까지의 패킷이 완성되야 일 처리를 시작한다는 것이다.  

static bool recvPacket(MySession& sess, std::string& cmd, std::string& data)    
{         
	size_t st = sess.m_recvs.find("syn::");        
	if (st == std::string::npos)            
		return false;          // [패킷작업]  패킷의 끝인 ::end 를 찾는 작업 등           
		if (dt)             
			data = sess.m_recvs.substr(sz+1, dt);          
	return true;     
}
 

* 참고로 자바로도 가능하다 (NIO2) 

이제 정답이 없다는데 이해했으리라 보고 굳이 구분을 하자면 나는 이렇게 구분한다.

@ 먼저 블로킹/논블로킹은 함수 호출에 관해서 국한 한다.
A가 B를 호출 했을 때 B가 A가 원하는 모든 일을 다 마치고 리턴하면 블로킹이고 다 마치기 전에 리턴하면 논블로킹이다. 
함수가 작동하는 시간하고는 무관하다. 1+1만 리턴하는 함수면 엄청 빠르게 리턴 할 지라도  원하는 행위를 다 했기 때문에 블로킹이다.

@ 동기/비동기는 각기 다른 쓰레드/프로세스/서버에서 일어나는 행위에 대한 동시성에 관한 이야기이다.
즉 쓰레드 혹은 프로세스가 분리되서 행위가 일어나는 데, A쓰레드가 B쓰레드의 결과를 계속 대기하고 있으면 동기이다. 
A(쓰레드,프로세스, 서버)가 자신의 일을 하다가 B의 결과를 이벤트로 받아서 처리하면 비동기이다. 

이런 구분방법은 거의 대부분의 경우에서 의사소통을 하기에 매우 분명하며 적절하다고 생각한다.


위의 글은 아래에도 실려있으며 Q/A 가 추가되어 있으니 참고하십시요. https://okky.kr/article/442803

+ Recent posts