Haskell có yêu cầu người thu gom rác không?


118

Tôi tò mò tại sao triển khai Haskell lại sử dụng GC.

Tôi không thể nghĩ ra trường hợp GC sẽ cần thiết bằng một ngôn ngữ thuần túy. Nó chỉ là một tối ưu hóa để giảm sao chép, hay nó thực sự cần thiết?

Tôi đang tìm mã ví dụ sẽ bị rò rỉ nếu không có GC.


14
Bạn có thể thấy loạt bài này kỳ diệu; nó bao gồm cách rác được tạo ra (và sau đó thu thập): blog.ezyang.com/2011/04/the-haskell-heap
Tom Crockett

5
có tài liệu tham khảo ở khắp mọi nơi bằng ngôn ngữ thuần túy! chỉ là tham chiếu không thể thay đổi .
Tom Crockett,

1
@pelotom Tham chiếu đến dữ liệu bất biến hoặc tham chiếu bất biến?
Pubby

3
Cả hai. Thực tế là dữ liệu được đề cập đến là bất biến sau thực tế là tất cả các tham chiếu là bất biến, tất cả các cách.
Tom Crockett,

4
Bạn chắc chắn sẽ quan tâm đến vấn đề tạm dừng , vì việc áp dụng lý luận này vào phân bổ bộ nhớ giúp hiểu lý do tại sao phân bổ giao dịch không thể được dự đoán tĩnh trong trường hợp chung . Tuy nhiên, có một số chương trình có thể dự đoán được việc phân bổ giao dịch, giống như chúng là một số chương trình có thể kết thúc mà không thực sự chạy chúng.
Paul R,

Câu trả lời:


218

Như những người khác đã chỉ ra, Haskell yêu cầu tự động , năng động quản lý bộ nhớ : quản lý bộ nhớ tự động là cần thiết vì quản lý bộ nhớ thủ công là không an toàn; quản lý bộ nhớ động là cần thiết vì đối với một số chương trình, thời gian tồn tại của một đối tượng chỉ có thể được xác định trong thời gian chạy.

Ví dụ, hãy xem xét chương trình sau:

main = loop (Just [1..1000]) where
  loop :: Maybe [Int] -> IO ()
  loop obj = do
    print obj
    resp <- getLine
    if resp == "clear"
     then loop Nothing
     else loop obj

Trong chương trình này, danh sách [1..1000]phải được lưu trong bộ nhớ cho đến khi người dùng gõ "xóa"; vì vậy thời gian tồn tại của điều này phải được xác định động, và đây là lý do tại sao quản lý bộ nhớ động là cần thiết.

Vì vậy, theo nghĩa này, việc phân bổ bộ nhớ động tự động là cần thiết và trong thực tế, điều này có nghĩa là: vâng , Haskell yêu cầu một bộ thu gom rác, vì bộ thu gom rác là trình quản lý bộ nhớ động tự động có hiệu suất cao nhất.

Tuy nhiên...

Mặc dù trình thu gom rác là cần thiết, chúng tôi có thể cố gắng tìm một số trường hợp đặc biệt mà trình biên dịch có thể sử dụng một sơ đồ quản lý bộ nhớ rẻ hơn so với thu gom rác. Ví dụ, đã cho

f :: Integer -> Integer
f x = let x2 = x*x in x2*x2

chúng ta có thể hy vọng trình biên dịch phát hiện x2có thể được phân bổ một cách an toàn khi ftrả về (thay vì đợi bộ thu gom rác phân bổ x2). Về cơ bản, chúng tôi yêu cầu trình biên dịch thực hiện phân tích thoát để chuyển đổi các phân bổ trong đống rác được thu gom thành các phân bổ trên ngăn xếp nếu có thể.

Điều này không quá vô lý để yêu cầu: trình biên dịch jhc haskell thực hiện điều này, mặc dù GHC thì không. Simon Marlow nói rằng người thu gom rác thế hệ của GHC khiến cho việc phân tích lối thoát hầu như không cần thiết.

jhc thực sự sử dụng một dạng phân tích thoát phức tạp được gọi là suy luận vùng . Xem xét

f :: Integer -> (Integer, Integer)
f x = let x2 = x * x in (x2, x2+1)

g :: Integer -> Integer
g x = case f x of (y, z) -> y + z

Trong trường hợp này, một phân tích thoát đơn giản sẽ kết luận rằng x2thoát khỏi f(vì nó được trả lại trong bộ tuple), và do đó x2phải được phân bổ trên đống rác được thu thập. Mặt khác, suy luận vùng có thể phát hiện x2có thể được phân bổ khi gtrả về; ý tưởng ở đây là x2nên được phân bổ trong gkhu vực của thay vì fkhu vực của.

Ngoài Haskell

Mặc dù suy luận vùng hữu ích trong một số trường hợp nhất định như đã thảo luận ở trên, nhưng dường như rất khó để dung hòa hiệu quả với đánh giá lười biếng (xem bình luận của Edward KmettSimon Peyton Jones ). Ví dụ, hãy xem xét

f :: Integer -> Integer
f n = product [1..n]

Người ta có thể bị cám dỗ để phân bổ danh sách [1..n]trên ngăn xếp và phân bổ nó sau khi ftrả về, nhưng điều này sẽ thật thảm khốc: nó sẽ thay đổi ftừ việc sử dụng bộ nhớ O (1) (dưới bộ sưu tập rác) thành bộ nhớ O (n).

Công việc mở rộng đã được thực hiện trong những năm 1990 và đầu những năm 2000 về suy luận vùng cho ngôn ngữ chức năng chặt chẽ ML. Mads Tofte, Lars Birkedal, Martin Elsman, Niels Hallenberg đã viết một bài hồi tưởng khá dễ đọc về công việc của họ về suy luận vùng, phần lớn trong số đó họ đã tích hợp vào trình biên dịch MLKit . Họ đã thử nghiệm với quản lý bộ nhớ hoàn toàn dựa trên khu vực (tức là không có bộ thu gom rác) cũng như quản lý bộ nhớ dựa trên khu vực / thu thập rác kết hợp và báo cáo rằng các chương trình thử nghiệm của họ chạy "nhanh hơn từ 10 lần đến 4 lần" so với rác thuần túy- các phiên bản đã sưu tầm.


2
Haskell có yêu cầu chia sẻ không? Nếu không, trong ví dụ đầu tiên của bạn, bạn có thể chuyển một bản sao của danh sách (tương ứng Nothing) cho cuộc gọi đệ quy loopvà phân bổ lại cái cũ - không xác định thời gian tồn tại. Tất nhiên không ai muốn triển khai không chia sẻ Haskell, vì nó chậm kinh khủng đối với cấu trúc dữ liệu lớn.
nimi,

3
Tôi thực sự thích câu trả lời này, mặc dù sự nhầm lẫn duy nhất của tôi là với ví dụ đầu tiên. Rõ ràng là nếu người dùng không bao giờ gõ "clear" thì nó có thể sử dụng bộ nhớ vô hạn (không có GC), nhưng đó không phải là rò rỉ chính xác vì bộ nhớ vẫn đang được theo dõi.
Pubby

3
C ++ 11 có một triển khai tuyệt vời của con trỏ thông minh. Về cơ bản nó sử dụng tính tham chiếu. Tôi đoán Haskell có thể bỏ thu gom rác để ủng hộ một thứ gì đó tương tự, và do đó trở nên xác định.
intrepidis

3
@ChrisNash - Không hoạt động. Con trỏ thông minh sử dụng tính năng đếm tham chiếu dưới mui xe. Việc đếm tham chiếu không thể đối phó với cấu trúc dữ liệu có chu kỳ. Haskell có thể tạo cấu trúc dữ liệu với các chu kỳ.
Stephen C,

3
Tôi không chắc liệu mình có đồng ý với phần cấp phát bộ nhớ động của câu trả lời này hay không. Chỉ vì chương trình không biết khi nào người dùng ngừng lặp lại tạm thời nên không làm cho nó động. Điều đó được xác định bởi liệu trình biên dịch có biết liệu điều gì đó sẽ đi ra ngoài ngữ cảnh hay không. Trong trường hợp của Haskell, khi điều đó được chính ngữ pháp định nghĩa chính thức, thì bối cảnh cuộc sống đã được biết đến. Tuy nhiên, bộ nhớ vẫn có thể động vì các biểu thức và kiểu danh sách được tạo động trong ngôn ngữ.
Timothy Swan

27

Hãy lấy một ví dụ tầm thường. Đưa ra điều này

f (x, y)

bạn cần phải phân bổ cặp (x, y)ở đâu đó trước khi gọi f. Khi nào bạn có thể phân bổ cặp đó? Bạn không có ý tưởng. Nó không thể được phân bổ khi ftrả về, vì fcó thể đã đặt cặp trong một cấu trúc dữ liệu (ví dụ f p = [p]:), do đó, thời gian tồn tại của cặp có thể phải lâu hơn thời gian trả về f. Bây giờ, giả sử rằng cặp đó đã được đưa vào một danh sách, bất cứ ai có thể tách danh sách ra khỏi danh sách để phân bổ cặp đó? Không, vì cặp có thể được chia sẻ (ví dụ let p = (x, y) in (f p, p):). Vì vậy, rất khó để biết khi nào thì cặp đôi này có thể được phân bổ.

Điều tương tự đối với hầu hết các phân bổ trong Haskell. Điều đó nói rằng, có thể có một phân tích (phân tích vùng) đưa ra giới hạn trên về thời gian tồn tại. Điều này hoạt động hợp lý trong các ngôn ngữ nghiêm ngặt, nhưng ít hơn trong các ngôn ngữ lười biếng (ngôn ngữ lười biếng có xu hướng gây đột biến hơn nhiều so với các ngôn ngữ nghiêm ngặt trong việc triển khai).

Vì vậy, tôi muốn xoay chuyển câu hỏi. Bạn nghĩ tại sao Haskell không cần GC. Bạn sẽ đề xuất việc cấp phát bộ nhớ được thực hiện như thế nào?


18

Trực giác của bạn rằng điều này có liên quan gì đó đến sự trong sạch cũng có một số sự thật.

Haskell được coi là tinh khiết một phần vì tác dụng phụ của các chức năng được tính đến trong chữ ký loại. Vì vậy, nếu một hàm có tác dụng phụ là in một cái gì đó, thì phải có một IOchỗ nào đó trong kiểu trả về của nó.

Nhưng có một hàm được sử dụng ngầm ở mọi nơi trong Haskell và chữ ký kiểu của nó không giải thích được, theo một nghĩa nào đó, là một tác dụng phụ. Cụ thể là chức năng sao chép một số dữ liệu và cung cấp cho bạn hai phiên bản trở lại. Về cơ bản, điều này có thể hoạt động theo nghĩa đen, bằng cách sao chép dữ liệu trong bộ nhớ hoặc 'ảo' bằng cách tăng một khoản nợ cần phải trả sau này.

Có thể thiết kế ngôn ngữ với các hệ thống kiểu hạn chế hơn (hoàn toàn là "tuyến tính") không cho phép chức năng sao chép. Từ quan điểm của một lập trình viên trong một ngôn ngữ như vậy, Haskell trông có vẻ hơi bất tịnh.

Trên thực tế, Clean , một người họ hàng của Haskell, có các loại tuyến tính (nghiêm ngặt hơn: duy nhất) và điều đó có thể cho bạn biết một số ý tưởng về việc không cho phép sao chép. Nhưng Clean vẫn cho phép sao chép đối với các loại "không phải là duy nhất".

Có rất nhiều nghiên cứu trong lĩnh vực này và nếu bạn đủ Google, bạn sẽ tìm thấy các ví dụ về mã tuyến tính thuần túy không yêu cầu thu gom rác. Bạn sẽ tìm thấy tất cả các loại hệ thống có thể báo hiệu cho trình biên dịch bộ nhớ nào có thể được sử dụng cho phép trình biên dịch loại bỏ một số GC.

Có nghĩa là các thuật toán lượng tử cũng hoàn toàn là tuyến tính. Mọi thao tác đều có thể hoàn nguyên và vì vậy không có dữ liệu nào có thể được tạo, sao chép hoặc phá hủy. (Chúng cũng tuyến tính theo nghĩa toán học thông thường.)

Nó cũng thú vị khi so sánh với Forth (hoặc các ngôn ngữ dựa trên ngăn xếp khác) có các hoạt động DUP rõ ràng giúp làm rõ khi nào xảy ra trùng lặp.

Một cách suy nghĩ khác (trừu tượng hơn) về vấn đề này là lưu ý rằng Haskell được xây dựng từ phép tính lambda được đánh máy đơn giản dựa trên lý thuyết về các phạm trù khép kín của Cartesian và các phạm trù như vậy được trang bị hàm đường chéo diag :: X -> (X, X). Một ngôn ngữ dựa trên một loại thể loại khác có thể không có điều đó.

Nhưng nói chung, lập trình tuyến tính thuần túy là quá khó để hữu ích, vì vậy chúng tôi giải quyết cho GC.


3
Kể từ khi tôi viết câu trả lời này, ngôn ngữ lập trình Rust đã trở nên phổ biến hơn một chút. Vì vậy, điều đáng nói là Rust sử dụng một hệ thống loại ish tuyến tính để kiểm soát quyền truy cập vào bộ nhớ và nó đáng để xem nếu bạn muốn xem những ý tưởng tôi đã đề cập được sử dụng trong thực tế.
sigfpe

14

Các kỹ thuật triển khai tiêu chuẩn được áp dụng cho Haskell thực sự yêu cầu GC nhiều hơn hầu hết các ngôn ngữ khác, vì chúng không bao giờ thay đổi các giá trị trước đó, thay vào đó tạo các giá trị mới, được sửa đổi dựa trên các giá trị trước đó. Vì điều này có nghĩa là chương trình liên tục phân bổ và sử dụng nhiều bộ nhớ hơn, một số lượng lớn các giá trị sẽ bị loại bỏ theo thời gian.

Đây là lý do tại sao các chương trình GHC có xu hướng có tổng số liệu phân bổ cao như vậy (từ gigabyte đến terabyte): chúng liên tục phân bổ bộ nhớ và chỉ nhờ vào GC hiệu quả mà chúng mới lấy lại được nó trước khi hết.


2
"chúng không bao giờ thay đổi các giá trị trước đó": bạn có thể kiểm tra haskell.org/haskellwiki/HaskellImplementorsWorkshop/2011/Takano , đó là về một tiện ích mở rộng GHC thử nghiệm sử dụng lại bộ nhớ.
gfour,

11

Nếu một ngôn ngữ (bất kỳ ngôn ngữ nào) cho phép bạn phân bổ động các đối tượng, thì có ba cách thực tế để giải quyết việc quản lý bộ nhớ:

  1. Ngôn ngữ chỉ có thể cho phép bạn cấp phát bộ nhớ trên ngăn xếp hoặc khi khởi động. Nhưng những hạn chế này hạn chế nghiêm trọng các loại tính toán mà một chương trình có thể thực hiện. (Trong thực tế. Về lý thuyết, bạn có thể mô phỏng các cấu trúc dữ liệu động trong (giả sử) Fortran bằng cách biểu diễn chúng trong một mảng lớn. Điều đó thật KHÓ ... và không liên quan đến cuộc thảo luận này.)

  2. Ngôn ngữ có thể cung cấp một cơ chế freehoặc một cách rõ ràng dispose. Nhưng điều này phụ thuộc vào lập trình viên để làm cho nó đúng. Bất kỳ sai lầm nào trong việc quản lý bộ nhớ có thể dẫn đến rò rỉ bộ nhớ ... hoặc tệ hơn.

  3. Ngôn ngữ (hoặc nghiêm túc hơn, việc triển khai ngôn ngữ) có thể cung cấp trình quản lý lưu trữ tự động cho lưu trữ được phân bổ động; tức là một số hình thức thu gom rác.

Tùy chọn khác duy nhất là không bao giờ lấy lại dung lượng đã cấp phát động. Đây không phải là một giải pháp thực tế, ngoại trừ các chương trình nhỏ thực hiện các phép tính nhỏ.

Áp dụng điều này cho Haskell, ngôn ngữ không có giới hạn là 1. và không có thao tác phân bổ thủ công như 2. Do đó, để có thể sử dụng cho những việc không tầm thường, triển khai Haskell cần phải bao gồm bộ thu gom rác .

Tôi không thể nghĩ ra trường hợp GC sẽ cần thiết bằng một ngôn ngữ thuần túy.

Có lẽ bạn muốn nói đến một ngôn ngữ chức năng thuần túy.

Câu trả lời là cần có GC để lấy lại các đối tượng đống mà ngôn ngữ PHẢI tạo. Ví dụ.

  • Một hàm thuần túy cần tạo các đối tượng heap vì trong một số trường hợp, nó phải trả lại chúng. Điều đó có nghĩa là chúng không thể được phân bổ trên ngăn xếp.

  • Thực tế là có thể có các chu kỳ (kết quả từ một let recví dụ) có nghĩa là cách tiếp cận đếm tham chiếu sẽ không hoạt động đối với các đối tượng đống.

  • Sau đó, có các đóng hàm ... cũng không thể được cấp phát trên ngăn xếp vì chúng có thời gian tồn tại (thường) độc lập với khung ngăn xếp mà chúng được tạo.

Tôi đang tìm mã ví dụ sẽ bị rò rỉ nếu không có GC.

Chỉ về bất kỳ ví dụ nào liên quan đến việc đóng hoặc cấu trúc dữ liệu dạng đồ thị sẽ bị rò rỉ trong những điều kiện đó.


2
Tại sao bạn nghĩ rằng danh sách các tùy chọn của bạn là đầy đủ? ARC trong Mục tiêu C, suy luận vùng trong MLKit và DDC, thu thập rác theo thời gian biên dịch trong Sao Thủy - tất cả chúng đều không phù hợp với danh sách này.
Dee Mon

@DeeMon - tất cả đều phù hợp với một trong những danh mục đó. Nếu bạn cho rằng không có thì đó là do bạn đang vẽ ranh giới danh mục quá chặt chẽ. Khi tôi nói "một số hình thức thu gom rác", tôi muốn nói đến bất kỳ cơ chế nào trong đó việc lưu trữ được thu hồi tự động.
Stephen C

1
C ++ 11 sử dụng con trỏ thông minh. Về cơ bản nó sử dụng tính tham chiếu. Nó mang tính xác định và tự động. Tôi rất thích thấy một triển khai Haskell sử dụng phương pháp này.
intrepidis

2
@ChrisNash - 1) Nó sẽ không hoạt động. Việc khai hoang cơ sở đếm tham chiếu làm rò rỉ dữ liệu nếu có chu kỳ ... trừ khi bạn có thể dựa vào mã ứng dụng để phá vỡ các chu kỳ. 2) Những người nghiên cứu những điều này đều biết (đối với những người nghiên cứu những điều này) rằng việc đếm tham chiếu hoạt động kém hơn khi so sánh với một bộ thu gom rác hiện đại (thực).
Stephen C

@DeeMon - ngoài ra, hãy xem câu trả lời của Reinerp về lý do tại sao suy luận vùng không thực tế với Haskell.
Stephen C

8

Bộ thu gom rác không bao giờ cần thiết, miễn là bạn có đủ bộ nhớ. Tuy nhiên, trên thực tế, chúng ta không có bộ nhớ vô hạn, và vì vậy chúng ta cần một số phương pháp để lấy lại bộ nhớ không còn cần thiết nữa. Trong các ngôn ngữ không tinh khiết như C, bạn có thể tuyên bố rõ ràng rằng bạn đã hoàn thành một số bộ nhớ để giải phóng nó - nhưng đây là một hoạt động đột biến (bộ nhớ bạn vừa giải phóng không còn an toàn để đọc), vì vậy bạn không thể sử dụng phương pháp này trong một ngôn ngữ thuần túy. Vì vậy, bằng cách nào đó, nó có thể phân tích tĩnh nơi bạn có thể giải phóng bộ nhớ (có thể là không thể trong trường hợp chung), rò rỉ bộ nhớ như một cái sàng (hoạt động tốt cho đến khi bạn hết) hoặc sử dụng GC.


Điều này giải đáp lý do tại sao GC nói chung là không cần thiết nhưng tôi quan tâm hơn đến Haskell nói riêng.
Pubby

10
Nếu về mặt lý thuyết, GC nói chung là không cần thiết, thì về mặt lý thuyết, nó cũng không cần thiết đối với Haskell.
ehird

@ehird Tôi muốn nói là cần thiết , tôi nghĩ trình kiểm tra chính tả của tôi đã hiểu sai ý nghĩa.
Pubby

1
Bình luận của Ehird vẫn được giữ :-)
Paul R.

2

GC là "phải có" trong các ngôn ngữ FP thuần túy. Tại sao? Hoạt động phân bổ và miễn phí là không tinh khiết! Và lý do thứ hai là, cấu trúc dữ liệu đệ quy bất biến cần GC để tồn tại bởi vì liên kết ngược tạo ra cấu trúc trừu tượng và không thể xác định được đối với tâm trí con người. Tất nhiên, backlink là điều may mắn, bởi vì việc sao chép các cấu trúc sử dụng nó rất rẻ.

Dù sao, nếu bạn không tin tôi, chỉ cần cố gắng triển khai ngôn ngữ FP và bạn sẽ thấy rằng tôi đúng.

CHỈNH SỬA: Tôi quên mất. Lười biếng là ĐỊA NGỤC nếu không có GC. Không tin tôi? Chỉ cần thử nó mà không có GC trong, chẳng hạn như C ++. Bạn sẽ thấy ... những thứ


1

Haskell là một ngôn ngữ lập trình không nghiêm ngặt, nhưng hầu hết các triển khai sử dụng lệnh gọi theo nhu cầu (lười biếng) để thực hiện tính không nghiêm ngặt. Trong cuộc gọi theo nhu cầu, bạn chỉ đánh giá nội dung khi nó đạt được trong thời gian chạy bằng cách sử dụng bộ máy "thu nhận" (các biểu thức chờ được đánh giá và sau đó tự ghi đè lên, luôn hiển thị để giá trị của chúng được sử dụng lại khi cần thiết).

Vì vậy, nếu bạn triển khai ngôn ngữ của mình một cách lười biếng bằng cách sử dụng côn, bạn đã trì hoãn mọi suy luận về vòng đời của đối tượng cho đến giây phút cuối cùng, đó là thời gian chạy. Vì bây giờ bạn không biết gì về các kiếp sống, điều duy nhất bạn có thể làm là thu gom rác ...


1
Trong một số trường hợp, phân tích tĩnh có thể chèn vào những mã thunks đó để giải phóng một số dữ liệu sau khi thunk được đánh giá. Sự phân bổ sẽ xảy ra trong thời gian chạy nhưng nó không phải GC. Điều này tương tự như ý tưởng về các con trỏ thông minh đếm tham chiếu trong C ++. Suy luận về thời gian tồn tại của đối tượng xảy ra trong thời gian chạy ở đó nhưng không có GC nào được sử dụng.
Dee Mon
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.