Lập trình socket HOWTO

tác giả:

Gordon McMillan

Ổ cắm

Tôi sẽ chỉ nói về ổ cắm INET (tức là IPv4), nhưng chúng chiếm ít nhất 99% số ổ cắm đang được sử dụng. Và tôi sẽ chỉ nói về ổ cắm STREAM (tức là TCP) - trừ khi bạn thực sự biết mình đang làm gì (trong trường hợp đó, HOWTO này không dành cho bạn!), bạn sẽ có được hoạt động và hiệu suất tốt hơn từ ổ cắm STREAM hơn bất kỳ thứ gì khác. Tôi sẽ cố gắng làm sáng tỏ bí ẩn về ổ cắm là gì, cũng như một số gợi ý về cách làm việc với ổ cắm chặn và không chặn. Nhưng tôi sẽ bắt đầu bằng việc nói về việc chặn ổ cắm. Bạn sẽ cần biết chúng hoạt động như thế nào trước khi xử lý các ổ cắm không chặn.

Một phần khó khăn khi hiểu những điều này là "ổ cắm" có thể mang nhiều nghĩa khác nhau một cách tinh tế, tùy thuộc vào ngữ cảnh. Vì vậy, trước tiên, hãy phân biệt giữa ổ cắm "máy khách" - điểm cuối của cuộc trò chuyện và ổ cắm "máy chủ", giống như một nhà điều hành tổng đài hơn. Ứng dụng khách (ví dụ: trình duyệt của bạn) chỉ sử dụng ổ cắm "máy khách"; máy chủ web mà nó đang nói đến sử dụng cả ổ cắm "máy chủ" và ổ cắm "máy khách".

Lịch sử

Trong số các dạng IPC khác nhau, cho đến nay, ổ cắm là loại phổ biến nhất. Trên bất kỳ nền tảng nào, có thể sẽ có các dạng IPC khác nhanh hơn, nhưng đối với giao tiếp đa nền tảng, socket là trò chơi duy nhất trong thị trấn.

Chúng được phát minh ở Berkeley như một phần của phiên bản BSD của Unix. Chúng lan truyền như cháy rừng nhờ internet. Có lý do chính đáng --- sự kết hợp của các ổ cắm với INET khiến việc nói chuyện với các máy tùy ý trên khắp thế giới trở nên dễ dàng đến mức không thể tin được (ít nhất là so với các chương trình khác).

Tạo một ổ cắm

Nói một cách đại khái, khi bạn nhấp vào liên kết đưa bạn đến trang này, trình duyệt của bạn đã thực hiện một số thao tác như sau:

# create và INET, ổ cắm STREAM
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# now kết nối với máy chủ web trên cổng 80 - cổng http thông thường
s.connect(("www.python.org", 80))

Khi connect hoàn thành, socket s có thể được sử dụng để gửi yêu cầu về văn bản của trang. Ổ cắm tương tự sẽ đọc câu trả lời và sau đó bị hủy. Đúng vậy, bị phá hủy. Ổ cắm máy khách thường chỉ được sử dụng cho một lần trao đổi (hoặc một tập hợp nhỏ các trao đổi tuần tự).

Những gì xảy ra trong máy chủ web phức tạp hơn một chút. Đầu tiên, máy chủ web tạo một "ổ cắm máy chủ":

# create và INET, ổ cắm STREAM
serverocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind ổ cắm cho máy chủ công cộng và cổng nổi tiếng
serversocket.bind((socket.gethostname(), 80))
# become ổ cắm máy chủ
serverocket.listen(5)

Một số điều cần lưu ý: chúng tôi đã sử dụng socket.gethostname() để ổ cắm có thể hiển thị với thế giới bên ngoài. Nếu chúng tôi đã sử dụng s.bind(('localhost', 80)) hoặc s.bind(('127.0.0.1', 80)) thì chúng tôi vẫn có ổ cắm "máy chủ", nhưng ổ cắm chỉ hiển thị trong cùng một máy. s.bind(('', 80)) chỉ định rằng ổ cắm có thể truy cập được bằng bất kỳ địa chỉ nào mà máy có.

Điều thứ hai cần lưu ý: số cổng thấp thường được dành riêng cho các dịch vụ "nổi tiếng" (HTTP, SNMP, v.v.). Nếu bạn đang chơi đùa, hãy sử dụng số cao đẹp (4 chữ số).

Cuối cùng, đối số của listen cho thư viện socket biết rằng chúng ta muốn nó xếp hàng tối đa 5 yêu cầu kết nối (mức tối đa bình thường) trước khi từ chối các kết nối bên ngoài. Nếu phần còn lại của mã được viết đúng, thì sẽ rất nhiều.

Bây giờ chúng ta có ổ cắm "máy chủ", nghe trên cổng 80, chúng ta có thể vào vòng lặp chính của máy chủ web

trong khi Đúng:
    kết nối # accept từ bên ngoài
    (clientsocket, địa chỉ) = serversocket.accept()
    # now làm gì đó với clientocket
    # in trong trường hợp này, chúng ta sẽ giả vờ đây là một máy chủ theo luồng
    ct = make_client_thread(clientsocket)
    ct.start()

Thực tế, có 3 cách chung để vòng lặp này có thể hoạt động - gửi một luồng để xử lý clientsocket, tạo một quy trình mới để xử lý clientsocket hoặc cơ cấu lại ứng dụng này để sử dụng các ổ cắm không chặn và ghép kênh giữa ổ cắm "máy chủ" của chúng tôi và bất kỳ clientsocket nào đang hoạt động bằng select. Thông tin thêm về điều đó sau. Điều quan trọng cần hiểu bây giờ là: đây là all, một ổ cắm "máy chủ". Nó không gửi bất kỳ dữ liệu nào. Nó không nhận được bất kỳ dữ liệu nào. Nó chỉ tạo ra ổ cắm "khách hàng". Mỗi clientsocket được tạo để phản hồi với một số ổ cắm "máy khách" other đang thực hiện connect() với máy chủ và cổng mà chúng tôi liên kết. Ngay sau khi tạo xong clientsocket đó, chúng tôi sẽ quay lại lắng nghe để có thêm kết nối. Hai "khách hàng" có thể thoải mái trò chuyện - họ đang sử dụng một số cổng được phân bổ động sẽ được tái sử dụng khi cuộc trò chuyện kết thúc.

IPC

Nếu bạn cần IPC nhanh giữa hai tiến trình trên một máy, bạn nên xem xét các đường dẫn hoặc bộ nhớ dùng chung. Nếu bạn quyết định sử dụng ổ cắm AF_INET, hãy liên kết ổ cắm "máy chủ" với 'localhost'. Trên hầu hết các nền tảng, việc này sẽ có một phím tắt xung quanh một vài lớp mã mạng và nhanh hơn một chút.

Xem thêm

Zz000zz tích hợp IPC đa nền tảng vào API cấp cao hơn.

Sử dụng ổ cắm

Điều đầu tiên cần lưu ý là ổ cắm "máy khách" của trình duyệt web và ổ cắm "máy khách" của máy chủ web là những con thú giống hệt nhau. Tức là đây là cuộc trò chuyện "ngang hàng". Hay nói cách khác là as the designer, you will have to decide what the rules of etiquette are for a conversation. Thông thường, ổ cắm connecting bắt đầu cuộc trò chuyện bằng cách gửi yêu cầu hoặc có thể là đăng nhập. Nhưng đó là quyết định thiết kế - không phải là quy tắc về ổ cắm.

Bây giờ có hai nhóm động từ được sử dụng để giao tiếp. Bạn có thể sử dụng sendrecv hoặc bạn có thể chuyển đổi ổ cắm máy khách của mình thành một con thú giống như tệp và sử dụng readwrite. Cái sau là cách Java trình bày các socket của nó. Tôi sẽ không nói về nó ở đây, ngoại trừ việc cảnh báo bạn rằng bạn cần sử dụng flush trên socket. Đây là những "tệp" được lưu vào bộ đệm và một lỗi phổ biến là write một cái gì đó và sau đó read để trả lời. Nếu không có flush trong đó, bạn có thể đợi câu trả lời mãi mãi vì yêu cầu có thể vẫn còn trong bộ đệm đầu ra của bạn.

Bây giờ chúng ta đến với vấn đề chính của ổ cắm - sendrecv hoạt động trên bộ đệm mạng. Chúng không nhất thiết phải xử lý tất cả byte bạn giao cho chúng (hoặc mong đợi từ chúng), vì trọng tâm chính của chúng là xử lý bộ đệm mạng. Nói chung, chúng quay trở lại khi bộ đệm mạng liên quan đã được lấp đầy (send) hoặc bị xóa (recv). Sau đó, họ cho bạn biết họ đã xử lý bao nhiêu byte. your có trách nhiệm gọi lại cho họ cho đến khi tin nhắn của bạn được xử lý hoàn toàn.

Khi recv trả về 0 byte, điều đó có nghĩa là phía bên kia đã đóng (hoặc đang trong quá trình đóng) kết nối. Bạn sẽ không nhận được thêm bất kỳ dữ liệu nào về kết nối này. Bao giờ. Bạn có thể gửi dữ liệu thành công; Tôi sẽ nói thêm về điều này sau.

Giao thức như HTTP chỉ sử dụng ổ cắm cho một lần truyền. Client gửi yêu cầu, sau đó đọc phản hồi. Thế thôi. Ổ cắm bị loại bỏ. Điều này có nghĩa là khách hàng có thể phát hiện phần cuối của phản hồi bằng cách nhận 0 byte.

Nhưng nếu bạn dự định sử dụng lại ổ cắm của mình để chuyển tiếp, bạn cần nhận ra rằng there is no EOT on a socket. tôi nhắc lại: nếu ổ cắm send hoặc recv trả về sau khi xử lý 0 byte, kết nối đã bị hỏng. Nếu kết nối not bị hỏng, bạn có thể đợi recv mãi mãi, vì ổ cắm not sẽ cho bạn biết rằng không còn gì để đọc (hiện tại). Bây giờ nếu bạn nghĩ về điều đó một chút, bạn sẽ nhận ra sự thật cơ bản về socket: messages must either be fixed length (yuck), or be delimited (nhún vai), or indicate how long they are (tốt hơn nhiều), or end by shutting down the connection. Sự lựa chọn hoàn toàn thuộc về bạn, (nhưng có một số cách đúng đắn hơn những cách khác).

Giả sử bạn không muốn kết thúc kết nối, giải pháp đơn giản nhất là một tin nhắn có độ dài cố định

lớp MySocket:
    """Chỉ lớp trình diễn
      - được mã hóa cho rõ ràng, không hiệu quả
    """

    def __init__(self, sock=None):
        nếu tất  Không :
            self.sock = socket.socket(
                            socket.AF_INET, socket.SOCK_STREAM)
        khác:
            self.sock = tất

    kết nối chắc chắn (tự, máy chủ, cổng):
        self.sock.connect((máy chủ, cổng))

    def mysend(self, msg):
        tổng số đã gửi = 0
        trong khi tổng số đã gửi < MSGLEN:
            đã gửi = self.sock.send(msg[totalsent:])
            nếu được gửi == 0:
                raise RuntimeError("kết nối ổ cắm bị hỏng")
            tổng số đã gửi = tổng số đã gửi + đã gửi

    def myreceive(self):
        khối = []
        byte_recd = 0
        trong khi byte_recd < MSGLEN:
            chunk = self.sock.recv(min(MSGLEN - bytes_recd, 2048))
            nếu đoạn == b'':
                raise RuntimeError("kết nối ổ cắm bị hỏng")
            chunk.append(chunk)
            byte_recd = byte_recd + len(chunk)
        trả về b''.join(chunks)

Mã gửi ở đây có thể sử dụng được cho hầu hết mọi sơ đồ nhắn tin - trong Python bạn gửi chuỗi và bạn có thể sử dụng len() để xác định độ dài của nó (ngay cả khi nó có nhúng các ký tự \0). Chủ yếu là mã nhận được phức tạp hơn. (Và trong C, điều đó không tệ hơn nhiều, ngoại trừ việc bạn không thể sử dụng strlen nếu thông báo đã nhúng \0s.)

Cải tiến dễ dàng nhất là biến ký tự đầu tiên của tin nhắn thành chỉ báo về loại tin nhắn và để loại xác định độ dài. Bây giờ bạn có hai recvs - ký tự đầu tiên lấy (ít nhất) ký tự đầu tiên đó để bạn có thể tra cứu độ dài và ký tự thứ hai trong vòng lặp để lấy phần còn lại. Nếu bạn quyết định đi theo lộ trình được phân tách, bạn sẽ nhận được một số kích thước khối tùy ý (4096 hoặc 8192 thường phù hợp với kích thước bộ đệm mạng) và quét những gì bạn đã nhận được để tìm dấu phân cách.

Một điều phức tạp cần lưu ý: nếu giao thức đàm thoại của bạn cho phép nhiều tin nhắn được gửi liên tiếp (không có dạng trả lời nào đó) và bạn chuyển cho recv một kích thước đoạn tùy ý, cuối cùng bạn có thể đọc phần đầu của tin nhắn sau. Bạn sẽ cần phải đặt nó sang một bên và giữ nó cho đến khi cần thiết.

Việc thêm tiền tố vào tin nhắn với độ dài của nó (giả sử là 5 ký tự số) trở nên phức tạp hơn, bởi vì (tin hay không tùy bạn), bạn có thể không nhận được tất cả 5 ký tự trong một recv. Khi chơi đùa, bạn sẽ thoát khỏi nó; nhưng khi tải mạng cao, mã của bạn sẽ rất nhanh bị hỏng trừ khi bạn sử dụng hai vòng lặp recv - vòng lặp đầu tiên để xác định độ dài, vòng lặp thứ hai để lấy phần dữ liệu của tin nhắn. Bẩn thỉu. Đây cũng là lúc bạn phát hiện ra rằng send không phải lúc nào cũng có thể loại bỏ mọi thứ chỉ trong một lần. Và mặc dù đã đọc điều này, nhưng cuối cùng bạn cũng sẽ hiểu được nó!

Vì lợi ích của không gian, xây dựng tính cách của bạn, (và duy trì vị thế cạnh tranh của tôi), những cải tiến này được coi là bài tập dành cho người đọc. Hãy chuyển sang dọn dẹp.

Dữ liệu nhị phân

Hoàn toàn có thể gửi dữ liệu nhị phân qua ổ cắm. Vấn đề chính là không phải tất cả các máy đều sử dụng cùng một định dạng cho dữ liệu nhị phân. Ví dụ: network byte order là big-endian, với byte quan trọng nhất đứng đầu, do đó, số nguyên 16 bit có giá trị 1 sẽ là hai byte hex 00 01. Tuy nhiên, hầu hết các bộ xử lý phổ biến (x86/AMD64, ARM, RISC-V), đều là endian nhỏ, với byte ít quan trọng nhất đầu tiên - 1 đó sẽ là 01 00.

Các thư viện socket yêu cầu chuyển đổi số nguyên 16 và 32 bit - ntohl, htonl, ntohs, htons trong đó "n" nghĩa là network và "h" nghĩa là host, "s" nghĩa là short và "l" nghĩa là long. Trong trường hợp thứ tự mạng là thứ tự máy chủ, chúng không làm gì cả, nhưng khi máy bị đảo ngược byte, chúng sẽ hoán đổi các byte xung quanh một cách thích hợp.

Trong các máy 64-bit ngày nay, biểu diễn ASCII của dữ liệu nhị phân thường nhỏ hơn biểu diễn nhị phân. Đó là bởi vì trong một khoảng thời gian đáng ngạc nhiên, hầu hết các số nguyên đều có giá trị 0 hoặc có thể là 1. Chuỗi "0" sẽ có hai byte, trong khi số nguyên 64 bit đầy đủ sẽ là 8. Tất nhiên, điều này không phù hợp lắm với các tin nhắn có độ dài cố định. Quyết định, quyết định.

Ngắt kết nối

Nói đúng ra, bạn phải sử dụng shutdown trên ổ cắm trước khi close nó. Zz002zz là lời khuyên cho ổ cắm ở đầu bên kia. Tùy thuộc vào lập luận mà bạn đưa ra, nó có thể có nghĩa là "Tôi sẽ không gửi nữa, nhưng tôi vẫn sẽ nghe" hoặc "Tôi không nghe, chết tiệt!". Tuy nhiên, hầu hết các thư viện socket đã quá quen với việc các lập trình viên bỏ qua việc sử dụng phần nghi thức này mà thông thường close cũng giống như shutdown(); close(). Vì vậy, trong hầu hết các trường hợp, không cần phải có shutdown rõ ràng.

Một cách để sử dụng shutdown một cách hiệu quả là trao đổi giống như HTTP. Máy khách gửi yêu cầu và sau đó thực hiện shutdown(1). Điều này báo cho máy chủ biết "Máy khách này đã gửi xong nhưng vẫn có thể nhận được." Máy chủ có thể phát hiện "EOF" bằng cách nhận 0 byte. Nó có thể cho rằng nó có yêu cầu đầy đủ. Máy chủ gửi trả lời. Nếu send hoàn thành thành công thì quả thực, khách hàng vẫn nhận được.

Python tiến thêm một bước nữa về tính năng tự động tắt và nói rằng khi ổ cắm được thu gom rác, nó sẽ tự động thực hiện close nếu cần. Nhưng dựa vào điều này là một thói quen rất xấu. Nếu ổ cắm của bạn biến mất mà không thực hiện close thì ổ cắm ở đầu bên kia có thể bị treo vô thời hạn vì cho rằng bạn đang làm chậm. Please close ổ cắm của bạn khi bạn hoàn tất.

Khi ổ cắm chết

Có lẽ điều tồi tệ nhất khi sử dụng ổ cắm chặn là điều xảy ra khi đầu bên kia chạm mạnh (không thực hiện close). Ổ cắm của bạn có thể bị treo. TCP là một giao thức đáng tin cậy và nó sẽ phải đợi rất rất lâu trước khi từ bỏ kết nối. Nếu bạn đang sử dụng chủ đề, toàn bộ chủ đề về cơ bản đã chết. Bạn không thể làm gì nhiều về nó. Miễn là bạn không làm điều gì đó ngu ngốc, chẳng hạn như giữ khóa trong khi thực hiện đọc chặn, thì luồng không thực sự tiêu tốn nhiều tài nguyên. not có cố gắng tiêu diệt luồng không - một phần lý do khiến luồng hiệu quả hơn các tiến trình là vì chúng tránh được chi phí chung liên quan đến việc tự động tái chế tài nguyên. Nói cách khác, nếu bạn cố gắng giết chết luồng, toàn bộ quá trình của bạn có thể sẽ gặp trục trặc.

Ổ cắm không chặn

Nếu bạn đã hiểu phần trước thì bạn đã biết hầu hết những điều bạn cần biết về cơ chế sử dụng ổ cắm. Bạn sẽ vẫn sử dụng các cuộc gọi giống nhau, theo những cách giống nhau. Chỉ là, nếu bạn làm đúng, ứng dụng của bạn sẽ gần như từ trong ra ngoài.

Trong Python, bạn sử dụng socket.setblocking(False) để làm cho nó không bị chặn. Trong C, nó phức tạp hơn, (có một điều, bạn sẽ cần phải chọn giữa hương vị BSD O_NONBLOCK và hương vị POSIX gần như không thể phân biệt được O_NDELAY, hoàn toàn khác với TCP_NODELAY), nhưng đó chính xác là cùng một ý tưởng. Bạn thực hiện việc này sau khi tạo ổ cắm nhưng trước khi sử dụng nó. (Thực ra, nếu bạn điên, bạn có thể chuyển đổi qua lại.)

Sự khác biệt cơ học chính là send, recv, connectaccept có thể quay lại mà không cần làm gì cả. Bạn có (tất nhiên) một số lựa chọn. Bạn có thể kiểm tra mã trả lại và mã lỗi và thường khiến bản thân phát điên. Nếu bạn không tin tôi, hãy thử nó một lần. Ứng dụng của bạn sẽ phát triển lớn hơn, nhiều lỗi và tệ hơn CPU. Vì vậy, hãy bỏ qua những giải pháp không cần thiết và làm đúng.

Sử dụng select.

Trong C, mã hóa select khá phức tạp. Trong Python, đó là một điều dễ dàng, nhưng nó đủ gần với phiên bản C nên nếu bạn hiểu select trong Python, bạn sẽ gặp một chút rắc rối với nó trong C:

Ready_to_read, Ready_to_write, in_error = \
               chọn.select(
                  tiềm năng_readers,
                  nhà văn tiềm năng,
                  tiềm năng_errs,
                  hết thời gian chờ)

Bạn chuyển cho select ba danh sách: danh sách đầu tiên chứa tất cả các ổ cắm mà bạn có thể muốn thử đọc; ổ cắm thứ hai là tất cả các ổ cắm mà bạn có thể muốn thử ghi vào và ổ cắm cuối cùng (thường để trống) mà bạn muốn kiểm tra lỗi. Bạn nên lưu ý rằng một socket có thể có nhiều hơn một danh sách. Cuộc gọi select đang bị chặn nhưng bạn có thể tạm dừng cuộc gọi đó. Nói chung đây là một việc hợp lý nên làm - hãy chờ một thời gian dài (ví dụ một phút) trừ khi bạn có lý do chính đáng để làm khác.

Đổi lại, bạn sẽ nhận được ba danh sách. Chúng chứa các socket thực sự có thể đọc, ghi được và có thể xảy ra lỗi. Mỗi danh sách này là một tập hợp con (có thể trống) của danh sách tương ứng mà bạn đã chuyển vào.

Nếu một ổ cắm nằm trong danh sách có thể đọc được đầu ra, bạn có thể gần như chắc chắn rằng recv trên ổ cắm đó sẽ trả về something. Ý tưởng tương tự cho danh sách có thể ghi. Bạn sẽ có thể gửi something. Có thể không phải tất cả những gì bạn muốn, nhưng something còn hơn không. (Trên thực tế, bất kỳ ổ cắm nào hoạt động bình thường sẽ trở lại dưới dạng có thể ghi - điều đó chỉ có nghĩa là không gian bộ đệm mạng bên ngoài có sẵn.)

Nếu bạn có ổ cắm "máy chủ", hãy đặt nó vào danh sách tiềm năng. Nếu nó xuất hiện trong danh sách có thể đọc được, accept của bạn (gần như chắc chắn) sẽ hoạt động. Nếu bạn đã tạo một ổ cắm mới tới connect cho người khác, hãy đặt nó vào danh sách tiềm năng. Nếu nó xuất hiện trong danh sách có thể ghi, rất có thể nó đã được kết nối.

Trên thực tế, select có thể hữu ích ngay cả khi chặn ổ cắm. Đó là một cách để xác định xem bạn có chặn hay không - ổ cắm trả về ở dạng có thể đọc được khi có thứ gì đó trong bộ đệm. Tuy nhiên, điều này vẫn không giúp ích được gì cho vấn đề xác định xem đầu bên kia đã xong chưa, hay chỉ đang bận việc khác mà thôi.

Portability alert: Trên Unix, select hoạt động cả với ổ cắm và tệp. Đừng thử điều này trên Windows. Trên Windows, select chỉ hoạt động với ổ cắm. Cũng lưu ý rằng trong C, nhiều tùy chọn ổ cắm nâng cao hơn được thực hiện khác trên Windows. Trên thực tế, trên Windows, tôi thường sử dụng các luồng (hoạt động rất tốt) với các ổ cắm của mình.