Lập trình với Socket

1. Tổng quan

Socket là một phương thức truyền thông của IPC (Inter-Process Comunication) cho phép các tiến trình trao đổi dữ liệu với nhau trên cùng một thiết bị hoặc trên các thiết bị khác nhau được kết nối thông qua Internet.

Socket là các file ở dạng endpoint, khi ghi data vào một đầu thì data sẽ được gửi sang một hoặc nhiều đầu khác. Vì là file nên nó được đại diện bởi một file socket disciptor.

Trong mô hình client-server, các ứng dụng giao tiếp với nhau bằng cách sử dụng socket:

  • Mỗi ứng dụng tạo ra một socket cho phép giao tiếp. Socket sau khi được tạo ra chỉ là 1 file raw và chưa có địa chỉ IP.
  • Server thực hiện gán cho socket địa chỉ IP mà client cũng có thể xác định địa chỉ IP đó.

Một socket được tạo ra bằng cách sử dụng socket() system call, hàm này sẽ trả về một file socket discriptor để sử dụng trong các system call khác.

          fd = socket(domain, type, protocol);

Thông tin được mô tả trong một file socket bao gồm: Domain, Type và Protocol.

Comunication Domain

Comuication domain trong socket xác định giao thức mạng được sử dụng và phạm vi liên lạc (giữa các ứng dụng trên cùng một máy chủ hay giữa các ứng dụng trên các máy chủ khác nhau ).

Các domain chính thường được sử dụng trong socket bao gồm:

  • UNIX domain (AF_UNIX)  cho phép giao tiếp giữa các ứng dụng trên cùng một máy chủ
  • IPv4 domain (AF_INET) cho phép giao tiếp giữa các ứng dụng chạy trên máy chủ được kết nối với nhau qua Internet Protocol (IPv4)
  • IPv6 domain (AF_INET) cho phép giao tiếp giữa các ứng dụng chạy trên máy chủ được kết nối với nhau qua Internet Protocol (IPv6)

Socket Type

Mọi socket được triển khai đều có ít nhất hai loại: stream và datagram. Các loại socket này đều được sử dụng trong UNIX và Internet domain (IPv4 và IPv6). Tuy nhiên, stream socket và datagram socket đều có ưu nhược điểm khác nhau.

Stream socket (SOCK_STREAM) cung cấp phương thức liên lạc đáng tin cậy (đảm bảo dữ liệu truyền đi được nguyên vẹn), cho phép giao tiếp theo hai chiều, dữ liệu được gửi theo dạng byte-stream (liên tục, không giữ nguyên ranh giới của các gói tin).

Datagram socket (SOCK_DGRAM) cho phép dữ liệu trao đổi dưới dạng message được gọi là datagram. Với datagram socket, ranh giới giữa các bản tin được giữ nguyên nhưng dữ liệu truyền đi không đảm bảo độ tin cậy. Data truyền có thể đến không đúng thứ tự, bị trùng lặp hoặc không đến được nơi nhận.

Socket System calls

Hệ điều hành cung cấp các socket system call sau:

  • socket() : tạo ra một socket mới
  • bind() : liên kết socket với một địa chỉ
  • listen() : cho phép một stream socket chấp nhận các kết nối từ các socket khác
  • accept() : chấp nhận kết nối trong một stream socket đã gọi hàm listen()
  • connect() : thiết lập kết nối với socket khác

Socket I/O có thể được thực hiện bằng cách sử dụng các system call như: read(), write() hoặc có thể sử dụng các system call riêng biệt cho socket: send(), recv(), sendto(), recvfrom().

2. Tạo ra một Socket: socket()

System call socket() tạo ra một socket mới:

  • domain: xác định giao thức mạng được sử dụng cho socket. Các giao thức thường được sử dụng như UNIX domain (AF_UNIX), IPv4 domain (AF_INET), IPv6 domain (AF_INET).
  • type: xác định loại socket sử dụng. Ví dụ như SOCK_STREM để tạo ra stream socket và SOCK_DGRAM để tạo ra dgram socket
  • protocol: thường để giá trị là 0 để chọn giao thức mặc định ứng với domain và type đã chọn

System call socket() trả về 1 file disciptor dùng để tham chiếu đến socket nếu thành công hoặc -1 nếu có lỗi.

3. Liên kết Socket với một địa chỉ: bind()

System call bind() gán một socket với một địa chỉ cụ thể (IP và port). Trong các ứng dụng cụ thể, ta cần gọi bind() trước khi sử dụng listen() (với sever) và connect() (với client)

  • sockfd: là một file discriptor được trả về từ hàm socket()
  • addr: là một con trỏ trỏ tới một struct địa chỉ mà socket sẽ được liên kết khi gọi hàm thành công. Đối số này phụ thuộc vào socket domain được sử dụng
DomainAddress structure
AF_UNIXsockaddr_un
AF_INETsockaddr_in
AF_INET6sockaddr_in6
  • addrlen: kích thước của struct địa chỉ

Nhìn vào bảng trên ta thấy với mỗi socket domain sử dụng một loại định dạng địa chỉ khác nhau. Ví dụ, với UNIX domain sử dụng đường dẫn file (pathnames), trong khi đó Internet domain sử dụng địa chỉ IP và port number. Tuy nhiên, các system call như bind() được sử dụng chung cho tất cả các socket domain nên chúng cần phải có có một struct chung cho các loại socket domain. Để đáp ứng yêu cầu này, API socket cung cấp một struct địa chỉ chung “stuct sockaddr” – giúp chuyển các struct địa chỉ dành riêng cho các miền khác nhau thành một loại duy nhất để sử dụng làm đối số trong các socket system call.

4. Stream Socket

Cơ chế hoạt động của stream socket được môt tả như sau:

Stream socket yêu cầu tạo một kết nối trước khi truyền dữ liệu. Tiến trình khởi tạo kết nối đóng vai trò là client, tiến trình nhận được yêu cầu kết nối là server. Sau khi khởi tạo socket bằng việc gọi socket(), server-client sẽ thực hiện các bước sau để thiết lập kết nối:

  • Server thực hiện gọi hàm bind() để gán với địa chỉ IP và port number vào socket đã khởi tạo. Sau đó thực hiện gọi hàm listen() để thông báo cho kernel về sự sẵn sàng chấp nhận các kết nối đến.
  • Client cũng thực hiện gọi hàm bind() để gán địa chỉ IP và port number trùng với địa chỉ đã gán cho socket ở server. Client thiết lập kết nối đến server bằng hàm connect().
  • Sever sau đó sẽ chấp nhận kết nối bằng cách sử dụng accept(). Nếu như accept() được gọi trước khi client gọi hàm connect(), tiến trình sẽ bị block cho tới khi có tín hiện connect được gửi tới.

Như vậy ta có thể thấy, client sẽ là tiến trình chủ động gửi yêu cầu kết nối (connect) tới server. Server là tiến trình bị động chờ tín hiệu kết nối đến từ client.

5. Lắng nghe kết nối: listen()

System call listen() thiết lập 1 trạng thái đặc biệt lên socket là trạng thái lắng nghe. Lúc này, socket sẽ nhận các bản tin bắt tay gửi từ internet và cho chúng vào hàng đợi backlog

Chúng ta không thể áp dụng listen() cho một socket đã được kết nối—tức là một socket mà trên đó connect() đã được thực hiện thành công hoặc một ổ cắm được trả về bởi system call accept()

Để hiểu mục đích của backlog, trước tiên chúng ta quan sát rằng client có thể gọi connect() trước khi server gọi accept(). Ví dụ: điều này có thể xảy ra do server đang bận xử lý với client khác. Điều này dẫn đến tồn tại một kết nối đang chờ xử lý. Do đo backlog tồn tại để thiết lập giới hạn số lượng kết nối đang chờ xử lý.

Hình ảnh minh họa kết nối đang chờ được xử lý:

6. Chấp nhận kết nối: accept()

System call accept() chấp nhận một kết nối đến socket được tham chiếu bởi bộ mô tả tệp sockfd. Nếu không có kết nối nào đang chờ xử lý khi hàm accept() được gọi, accept() sẽ block chương trình cho đến khi có yêu cầu kết nối.

Mỗi khi gọi hàm accept(), OS sẽ lấy ra 1 phần tử trong hàng đợi backlog, khởi tạo cho nó 1 socket làm đầu đường ống để kết nối đến socket của đầu ống bên kia.

Chú ý: accept() tạo ra một socket mới (file discriptor của nó được trả về thông qua hàm accept) và chính socket mới này được kết nối với socket ở phía client đã thực connet(). Socket nghe (sockfd) vẫn mở và có thể được sử dụng để chấp nhận các kết nối tiếp theo. Điều này giúp server có thể giao tiếp với nhiều client.

Các đối số còn lại trả về thông tin địa chỉ socket của client. Đối số addr trỏ đến một struct được sử dụng để trả về địa chỉ socket của client, addrlen chứa kích thước của structaddr đó. Nếu chúng ta không quan tâm đến thông tin này, có thể truyền NULL.

7. Kết nối tới một Socket: connect()

Để thiết lập kết nối, client cần gửi bản tin bắt tay đến server thông qua hàm connect(). Nếu phía server đồng ý kết nối thì một đường truyền sẽ được thiết lập giữa socket của client và socket của server.

System call connect() kết nối socket ở phía client được tham chiếu bởi file discriptor (sockfd) với listening socket ở phía server có địa chỉ được xác định bởi addr và addrlen.

  • sockfd: File descriptor của socket.
  • addr: Con trỏ đến một struct sockaddr chứa thông tin địa chỉ của server mà client muốn kết nối.
  • addrlen: Kích thước của cấu trúc địa chỉ.

8. I/O trên Stream Socket

Sau khi kết nối được thiết lập, dữ liệu có thể được gửi nhận thông qua việc đọc ghi vào socket file giống như file bình thường thông qua các system call: read(), write()

Bên cạnh đó, OS thiết kế riêng 1 số hàm chuyên dùng gửi nhận dữ liệu từ socket:

  • ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags)
  • ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags)

Hình ảnh minh họa luồng kết trong UNIX domain:

Ngoài ra, để chấm dứt kết nối, chúng ta sử dụng close(sockfd)

9. Ví dụ chương trình thực hiện giao tiếp server-client thông qua socket

Chương trình server:

Chương trình Client:

Kết quả sau khi chạy 2 chương trình:

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top