2022.7.8 추가
Thunk
, Chunk
를 혼용해서 쓰던 걸 바로 잡습니다.
Thunk
: 아직 평가 되기 전 코드를 의미합니다. 이 문서에서는 혼동을 피하기 위해 <Thunk>
로 표기하겠습니다.
Chunk
: 전체를 이루는 각 부분들을 덩어리Chunk라고 합니다. Lazy와 관계 없이 부분을 뜻하는, 단어 뜻 그대로 덩어리란 의미로 읽으면 됩니다.
ex. Strict ByteString
도 Lazy ByteString
도 평가 하기 전에는 <Thunk>
로 남아 있습니다.
ex. fromChunks
를 이용해서 Strict ByteString Chunk
를 여러개 모아 하나의 Lazy ByteString
으로 만들 수 있습니다.
※ 바로 잡는데 도움을 주신 준규님, Ailrun님 감사합니다.
라이브러리 때문에 Lazy
와 Strict
를 혼용해서 써야 되는 상황이 생겨 헤매다가, 문득 Lazy
가 생각처럼 동작하는지 궁금해서 테스트 해봤습니다.
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE BangPatterns #-}
import Data.ByteString.Lazy as BL
import Data.ByteString as BS
func1 :: BS.ByteString -> BS.ByteString -> (BS.ByteString, BS.ByteString)
= (a , b)
func1 a b
func2 :: BL.ByteString -> BL.ByteString -> (BL.ByteString, BL.ByteString)
= (a , b)
func2 a b
-- BL에 bang을 붙이면 BS가 될까요?
func3 :: BL.ByteString -> BL.ByteString -> (BL.ByteString, BL.ByteString)
!a !b = (a , b) func3
Bang! 패턴에 대한 설명은 Strict와 Lazy 포스트를 참고하세요.
*Main> :set -XOverloadedStrings
*Main> let t1 = func1 undefined "a"
*Main> let t2 = func2 undefined "b"
*Main> let t3 = func3 undefined "c"
*Main> snd t1
"a"
*Main> snd t2
"b"
*Main> snd t3
*** Exception: Prelude.undefined ...
Data.ByteString
이 Strict
라 하면 t1
과 Lazy
에 bang
을 붙인 t3
가 같은 동작을 해야 될 것 같은데 그렇지 않습니다. 예상했던 Strict
의 동작인가요? ByteString Strict
와 ByteString Lazy
차이가 뭘까요?
Data.ByteString.Internal
에 있는 정의
-- 효율적으로 공간을 활용하는 Word8 벡터입니다.
-- 실제 데이터의 시작을 가리키는 포인터, 오프셋,길이로 표현됩니다.
-- 모든 필드에는 bang이 붙어 있습니다.
data ByteString = PS {-# UNPACK #-} !(ForeignPtr Word8) -- payload
{-# UNPACK #-} !Int -- offset
{-# UNPACK #-} !Int -- length
deriving (Typeable)
1 ForeignPtr은 외부 객체 참조 포인터입니다.
Data.ByteString.Lazy.Internal
에 있는 정의
-- Chunk에는 Strict ByteString과 재귀적으로 Lazy ByteString이 같이 있습니다.
import qualified Data.ByteString.Internal as S
data ByteString = Empty | Chunk {-# UNPACK #-} !S.ByteString ByteString
deriving (Typeable)
Lazy
는 Strict ByteString
을 여러 Chunk로 쪼개어 갖고 있는 모양입니다. 달리 말하면, Chunk
란 값 생성자로 여러 번 쌓여 있는 상태입니다.
Strict
에는 필드들이 모두 bang
이 붙어 있으므로 PS
값 생성자가 평가될 때, 모든 필드들은 <Thunk>
상태로 둘 수 없다는 말입니다. 물론 PS
생성자가 평가되기 전에는 <Thunk>
로 남아 있을 겁니다.
Empty
Chunk !(포인터, 오프셋, 길이) Empty
Chunk !(포인터, 오프셋, 길이) ( Chunk !(포인터, 오프셋, 길이) Empty )
Chunk !(포인터, 오프셋, 길이) ( Chunk !(포인터, 오프셋, 길이) ( Chunk !(포인터, 오프셋, 길이) Empty ))
-- 이런 모양들이 될 수 있습니다.
연산들이 가장 바깥에 있는 Chunk
를 평가할 때 하나의 바이트열만 strict
하게 두고, 안에 있는 Chunk
는 <Thunk>
로 둡니다. 만일 오프셋, 길이를 봐서 다음 안 쪽의 Chunk
로 넘어가면 그 때, 안 쪽 Chunk
를 평가 할 겁니다.
단순히 bang
을 붙였냐 안 붙였냐의 차이가 아닙니다.
Strict ByteString
이라 해서 무조건 ByteString
만 만나면 Strict
하게 동작하는 게 아니라, Chunk없이 전체 문자열을 가리키는 포인터를 가져오고, Lazy ByteString
은 Chunk
로 나누어 필요할 때 조금씩, 조금씩 각 조각을 가리키는 포인터를 가져오게 되어 있습니다.
이렇게 보면, Lazy ByteString은 단위를 1바이트보다 크게 잡은 배열에 값 생성자와 묶어 하나의 항목만 strict하게 평가되도록 bang
이 붙어 있는 것으로 볼 수 있습니다.
Strict
냐 Lazy
냐가 궁금해진 이유는, 하나의 함수에서 다른 함수로 ByteString을 건네 줄 때, 계속해서 Copy가 일어나면 어마 어마하게 비효율적일텐데 어떻게 처리되고 있는지가 궁금해서였습니다. Strict
도, Lazy
도 똑 같이 <Thunk>
로 넘어가니 실제 복사는 일어나지 않다가, 평가될 때 복사가 일어날텐데, 그 전까지는 Lazy는 첫 번째 Chunk
안에 들어 있는 PS
생성자가 가진 Strict
포인터를 복사할테고, Strict
는 PS
가 가진 Strict
포인터를 복사할 겁니다. 함수간 전달에서는 크게 차이가 날 것 같지 않습니다. immutable한 데이터를 가리키는 포인터만을 복사하는 것으로 보입니다.
mutable 해야 하는 상황이 생기면 어떻게 될까요?
ByteString
두 개를 붙인다 하면 새로운 ByteString
으로 두 개의 실제 데이터 내용을 모두 복사해와야 합니다. 예를들어 간단하게 앞에 한 바이트, 한 바이트 추가한다고 리스트처럼 생각하고 작업하다간 퍼포먼스가 한없이 떨어지게 됩니다. 빈번하게 mutable해야 한다면, Strict ByteString
으로 해결할 수 있는 문제가 아닙니다.
(이럴 때 Builder를 씁니다.)
둘이 대부분 같은 인터페이스가 구현되어 있어 사용할 때는 신경쓰지 않아도 되겠지 했는데, 그렇지 않습니다. 함수가 BL.ByteString
을 받도록 설계하면, 이 함수는 BS.ByteString
을 받을 수 없습니다. 이유가 대부분 안에서 다른 라이브러리에 있는 ByteString
관련 함수들을 가져다 쓰는데, 해당 라이브리리들의 버전을 모두 바꿔줘야 합니다. 때로는 Strict
, Lazy
둘 중 하나만 지원하는 라이브러리들도 있습니다.
둘 중 어떤 걸 어떤 때 써야하는지 정리가 필요해 보이는데, 지금 당장은 딱 필요한 순간에 전체 바이트열이 필요한 경우가 많다면 Strict
로, 그렇지 않으면 Lazy
를 쓴다정도만 떠오릅니다.
둘 모두 꼭 필요한 때가 아니면 포인터로 머물러 있는데, 꼭 필요한 때를 상상해 보면 출력을 한다든지, 파싱을 한다든지 내용이 필요할 때입니다. 파싱을 가정해 보면 Strict ByteString
은 안에 있는 포인터가 가리키는 메모리에서 내용을 한 번에 모두 읽어 옵니다. Lazy는 일단 첫 번째 Chunk
가 가리키는 것만 읽어오고, 더 필요하면 또 읽어올 겁니다.
ForeignPtr는 외부에 유지되는 객체에 대한 참조를 말합니다. 외부란, 하스켈 코드를 돌리는 런타임 시스템(더 구체적으론 스토리지 관리자)의 관리를 받지 않는 오브젝트란 뜻입니다. 하스켈의 힙이나 스택에서 더 이상 이 포인터를 보는 참조가 없으면 외부에 있는 릴리즈 담당 루틴을 부르게 됩니다. 더 깊이 보기 전에 추측해보면, C 등으로 alloc하고 해당 메모리를 가리키는 포인터를 하스켈이 받아서 작업할 때 쓸 것 같습니다.↩︎