Chat client server C++ qua socket TCP: nhiều người chat cùng lúc bằng thread

Chat client server C++ qua socket TCP: nhiều người chat cùng lúc bằng thread - Khuê Nguyễn Creator

Trong bài này chúng ta sẽ làm ứng dụng chat client server C++: một chương trình server chạy trên máy, nhiều người mở chương trình client kết nối vào và chat chung một phòng, ai gõ gì thì mọi người khác đều thấy. Đây là project cuối của chương ứng dụng, kết hợp lập trình socket TCP với đa luồng, mutex mà chúng ta đã học ở bài lập trình đa luồng.

Bài 40 trong Serie Lập trình C++ từ A tới Z

Yêu cầu của chương trình chat client server C++

  1. Server nghe ở một cổng (mặc định 5000), nhận nhiều client cùng lúc.
  2. Client kết nối tới IP và cổng của server, nhập tên, sau đó mỗi dòng gõ vào là một tin nhắn.
  3. Server chuyển tin của một người tới tất cả người còn lại dạng [Ten] noi dung, báo khi có người vào hoặc rời phòng.
  4. Gõ /quit để rời phòng.

Code dùng socket POSIX nên chạy trực tiếp trên Linux, macOS và WSL trên Windows. Phần cuối bài có ghi chú những chỗ cần sửa nếu muốn chạy bằng Winsock trên Windows.

Socket TCP là gì?

Socket là “đầu cắm” để chương trình gửi nhận dữ liệu qua mạng, hệ điều hành cung cấp sẵn các hàm C như socket(), connect(), send(), recv(). TCP là giao thức đảm bảo dữ liệu tới nơi đầy đủ và đúng thứ tự, phù hợp cho chat: không ai muốn tin nhắn bị mất chữ hay đảo câu.

Một kết nối TCP xác định bằng địa chỉ IP (máy nào) và cổng (chương trình nào trên máy đó). Ở bài này server và client chạy cùng một máy nên IP là 127.0.0.1, địa chỉ chỉ chính máy mình.

Trình tự gọi hàm socket TCP: server socket, bind, listen, accept; client socket, connect; rồi send, recv và close

  • Server: socket() tạo socket, bind() gắn vào cổng 5000, listen() bắt đầu chờ, accept() chờ tới khi có client và trả về một socket mới dành riêng cho client đó. Socket ban đầu tiếp tục dùng để chờ client khác.
  • Client: socket() rồi connect() tới IP và cổng của server.
  • Sau khi kết nối, hai bên dùng send() và recv(). Bên nào đóng kết nối thì recv() ở bên kia trả về 0.

Vì sao cần nhiều thread?

accept() và recv() đều chặn: chương trình đứng chờ ở đó cho tới khi có kết nối hoặc có dữ liệu. Nếu server chỉ có một luồng, nó đang chờ An gõ phím thì không thể nhận tin của Bình, cũng không nhận được người mới vào phòng.

Cách đơn giản nhất là mỗi client một thread: luồng chính của server chỉ lo accept(), mỗi kết nối mới được giao cho một std::thread riêng. Client cũng có 2 luồng: luồng chính đọc bàn phím, luồng phụ nhận tin từ server để in ra ngay cả khi người dùng chưa gõ gì.

Kiến trúc ứng dụng chat client server C++: server mỗi client một thread, danh sách client khóa bằng mutex

Code server

Gửi tin và tách dòng

// Gui het chuoi: send() co the chi gui duoc mot phan
bool guiHet(int s, const std::string& tin)
{
    std::size_t daGui = 0;
    while (daGui < tin.size())
    {
        ssize_t n = send(s, tin.data() + daGui, tin.size() - daGui, MSG_NOSIGNAL);
        if (n <= 0)
            return false;
        daGui += n;
    }
    return true;
}

send() không hứa gửi hết dữ liệu trong một lần gọi, nó trả về số byte đã gửi được. Hàm guiHet lặp cho tới khi gửi đủ. Cờ MSG_NOSIGNAL để chương trình không bị hệ điều hành tắt bằng tín hiệu SIGPIPE khi gửi vào một kết nối mà client đã đóng.

// Doc mot dong ket thuc bang '\n'. TCP la dong byte, mot lan recv() co the
// nhan nua dong hoac nhieu dong, nen phai gom vao bo dem roi tach theo '\n'.
bool docDong(int s, std::string& boDem, std::string& dong)
{
    while (true)
    {
        std::size_t vt = boDem.find('\n');
        if (vt != std::string::npos)
        {
            dong = boDem.substr(0, vt);
            boDem.erase(0, vt + 1);
            return true;
        }
        char buf[512];
        ssize_t n = recv(s, buf, sizeof(buf), 0);
        if (n <= 0)
            return false;                           // client ngat ket noi
        boDem.append(buf, n);
    }
}

Đây là chỗ người mới học socket hay sai nhất. TCP là một dòng byte liên tục, không có khái niệm “tin nhắn”. Client gửi 2 tin “Chao” và “Ban khoe khong” thì server có thể nhận cả hai trong một lần recv(), hoặc nhận “Chao\nBan kh” rồi lần sau mới nhận “oe khong\n”. Vì vậy ta tự quy ước mỗi tin kết thúc bằng \n, dữ liệu nhận được gom vào boDem, có đủ một dòng thì mới cắt ra xử lý, phần còn lại để dành cho lần sau.

Lưu ý: Đừng bao giờ coi một lần recv() là một tin nhắn. Khi chạy thử trên cùng một máy, gần như lần nào cũng nhận đúng một tin, nên lỗi này không lộ ra. Tới khi chạy qua mạng thật hoặc tin dài, tin nhắn bắt đầu bị cắt đôi hoặc dính vào nhau.

Danh sách client và mutex

void guiCaPhong(const std::string& tin, int tru)
{
    std::lock_guard<std::mutex> khoa(khoaDs);
    for (int s : dsClient)
        if (s != tru)
            guiHet(s, tin);
}

dsClient lưu socket của mọi người đang trong phòng. Nhiều thread cùng thêm, xóa và duyệt vector này: thread của An đang duyệt để gửi tin, đúng lúc thread của Bình xóa Bình khỏi vector, vector có thể bị hỏng. Mọi chỗ đụng tới dsClient đều khóa khoaDs bằng std::lock_guard như bài đa luồng.

Tương tự, std::cout cũng được nhiều thread dùng chung, nên log của server đi qua hàm inLog có khóa riêng. Không có khóa này, 2 dòng log in cùng lúc có thể bị xen chữ vào nhau.

Thread phục vụ mỗi client

void phucVuClient(int s)
{
    std::string boDem, ten, dong;
    if (docDong(s, boDem, ten))                     // dong dau tien la ten
    {
        {
            std::lock_guard<std::mutex> khoa(khoaDs);
            dsClient.push_back(s);
        }
        inLog(ten + " vao phong");
        guiCaPhong("*** " + ten + " vao phong\n", s);

        while (docDong(s, boDem, dong) && dong != "/quit")
        {
            inLog("[" + ten + "] " + dong);
            guiCaPhong("[" + ten + "] " + dong + "\n", s);
        }

        {
            std::lock_guard<std::mutex> khoa(khoaDs);
            dsClient.erase(std::remove(dsClient.begin(), dsClient.end(), s), dsClient.end());
        }
        inLog(ten + " roi phong");
        guiCaPhong("*** " + ten + " roi phong\n", s);
    }
    close(s);
}

Dòng đầu tiên client gửi là tên. Sau đó thread lặp đọc từng dòng và gửi cho cả phòng, trừ chính người gửi (tham số tru). Client gõ /quit hoặc mất kết nối thì docDong trả về false, thread xóa client khỏi danh sách, báo cả phòng rồi đóng socket.

Hàm main của server

int main(int argc, char* argv[])
{
    int cong = argc > 1 ? std::stoi(argv[1]) : 5000;

    int server = socket(AF_INET, SOCK_STREAM, 0);
    int bat = 1;
    setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &bat, sizeof(bat));

    sockaddr_in diaChi{};
    diaChi.sin_family = AF_INET;
    diaChi.sin_addr.s_addr = INADDR_ANY;            // nhan ket noi tu moi dia chi IP cua may
    diaChi.sin_port = htons(cong);
    if (bind(server, reinterpret_cast<sockaddr*>(&diaChi), sizeof(diaChi)) < 0)
    {
        perror("bind");
        return 1;
    }
    listen(server, 8);
    inLog("Server dang nghe o cong " + std::to_string(cong));

    while (true)
    {
        sockaddr_in khach{};
        socklen_t len = sizeof(khach);
        int s = accept(server, reinterpret_cast<sockaddr*>(&khach), &len);
        if (s < 0)
            continue;
        inLog(std::string("Ket noi moi tu ") + inet_ntoa(khach.sin_addr) + ":" + std::to_string(ntohs(khach.sin_port)));
        std::thread(phucVuClient, s).detach();      // moi client mot thread rieng
    }
}
  • htons() đổi số cổng sang thứ tự byte của mạng (big-endian), ntohs() thì ngược lại. Máy tính x86 lưu số theo little-endian nên bắt buộc phải đổi.
  • SO_REUSEADDR cho phép chạy lại server ngay sau khi tắt. Không có nó, bind() sẽ báo lỗi “Address already in use” trong khoảng một phút sau khi tắt server.
  • std::thread(...).detach() tách thread ra chạy độc lập, luồng chính không cần join() mà quay lại accept() ngay.
  • Các hàm socket là API C, nhận sockaddr* chung cho mọi loại địa chỉ, nên phải ép kiểu từ sockaddr_in* (địa chỉ IPv4) bằng reinterpret_cast.

Code client

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <atomic>
#include <cstdio>
#include <iostream>
#include <string>
#include <thread>

bool guiHet(int s, const std::string& tin)
{
    std::size_t daGui = 0;
    while (daGui < tin.size())
    {
        ssize_t n = send(s, tin.data() + daGui, tin.size() - daGui, MSG_NOSIGNAL);
        if (n <= 0)
            return false;
        daGui += n;
    }
    return true;
}

int main(int argc, char* argv[])
{
    const char* ip = argc > 1 ? argv[1] : "127.0.0.1";
    int cong = argc > 2 ? std::stoi(argv[2]) : 5000;

    int s = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in diaChi{};
    diaChi.sin_family = AF_INET;
    diaChi.sin_port = htons(cong);
    if (inet_pton(AF_INET, ip, &diaChi.sin_addr) != 1)
    {
        std::cerr << "Dia chi IP khong hop le\n";
        return 1;
    }
    if (connect(s, reinterpret_cast<sockaddr*>(&diaChi), sizeof(diaChi)) < 0)
    {
        perror("connect");
        return 1;
    }

    std::string ten;
    std::cout << "Da ket noi " << ip << ':' << cong << ". Nhap ten: " << std::flush;
    std::getline(std::cin, ten);
    guiHet(s, ten + "\n");

    std::atomic<bool> dangThoat{false};
    std::thread luongNhan([s, &dangThoat]() {      // luong rieng: nhan tin va in ra
        char buf[512];
        ssize_t n;
        while ((n = recv(s, buf, sizeof(buf), 0)) > 0)
            std::cout.write(buf, n) << std::flush;
        if (!dangThoat)
            std::cout << "Mat ket noi toi server\n";
    });

    std::string dong;                               // luong chinh: doc ban phim va gui
    while (std::getline(std::cin, dong))
    {
        if (dong == "/quit")
            dangThoat = true;
        if (!guiHet(s, dong + "\n") || dong == "/quit")
            break;
    }
    dangThoat = true;
    shutdown(s, SHUT_RDWR);                         // recv() o luong nhan tra ve 0, luong ket thuc
    luongNhan.join();
    close(s);
    return 0;
}
  • inet_pton() đổi chuỗi IP "127.0.0.1" sang dạng số, trả về khác 1 nếu chuỗi không phải IP hợp lệ.
  • Luồng nhận là một lambda, in thẳng những gì nhận được vì server đã gửi kèm \n ở cuối mỗi tin.
  • dangThoat là std::atomic<bool> vì được ghi ở luồng chính và đọc ở luồng nhận. Khi tự gõ /quit thì không in “Mat ket noi toi server”.
  • shutdown() đóng kết nối, làm recv() đang chặn ở luồng nhận trả về 0, luồng nhận kết thúc và join() hoàn tất. Không có bước này, join() sẽ chờ mãi.

Code đầy đủ của server

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <cstdio>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

std::vector<int> dsClient;          // socket cua cac client dang trong phong
std::mutex khoaDs;                  // bao ve dsClient
std::mutex khoaIn;                  // bao ve std::cout

void inLog(const std::string& s)
{
    std::lock_guard<std::mutex> khoa(khoaIn);
    std::cout << s << std::endl;
}

// Gui het chuoi: send() co the chi gui duoc mot phan
bool guiHet(int s, const std::string& tin)
{
    std::size_t daGui = 0;
    while (daGui < tin.size())
    {
        ssize_t n = send(s, tin.data() + daGui, tin.size() - daGui, MSG_NOSIGNAL);
        if (n <= 0)
            return false;
        daGui += n;
    }
    return true;
}

void guiCaPhong(const std::string& tin, int tru)
{
    std::lock_guard<std::mutex> khoa(khoaDs);
    for (int s : dsClient)
        if (s != tru)
            guiHet(s, tin);
}

// Doc mot dong ket thuc bang '\n'. TCP la dong byte, mot lan recv() co the
// nhan nua dong hoac nhieu dong, nen phai gom vao bo dem roi tach theo '\n'.
bool docDong(int s, std::string& boDem, std::string& dong)
{
    while (true)
    {
        std::size_t vt = boDem.find('\n');
        if (vt != std::string::npos)
        {
            dong = boDem.substr(0, vt);
            boDem.erase(0, vt + 1);
            return true;
        }
        char buf[512];
        ssize_t n = recv(s, buf, sizeof(buf), 0);
        if (n <= 0)
            return false;                           // client ngat ket noi
        boDem.append(buf, n);
    }
}

void phucVuClient(int s)
{
    std::string boDem, ten, dong;
    if (docDong(s, boDem, ten))                     // dong dau tien la ten
    {
        {
            std::lock_guard<std::mutex> khoa(khoaDs);
            dsClient.push_back(s);
        }
        inLog(ten + " vao phong");
        guiCaPhong("*** " + ten + " vao phong\n", s);

        while (docDong(s, boDem, dong) && dong != "/quit")
        {
            inLog("[" + ten + "] " + dong);
            guiCaPhong("[" + ten + "] " + dong + "\n", s);
        }

        {
            std::lock_guard<std::mutex> khoa(khoaDs);
            dsClient.erase(std::remove(dsClient.begin(), dsClient.end(), s), dsClient.end());
        }
        inLog(ten + " roi phong");
        guiCaPhong("*** " + ten + " roi phong\n", s);
    }
    close(s);
}

int main(int argc, char* argv[])
{
    int cong = argc > 1 ? std::stoi(argv[1]) : 5000;

    int server = socket(AF_INET, SOCK_STREAM, 0);
    int bat = 1;
    setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &bat, sizeof(bat));

    sockaddr_in diaChi{};
    diaChi.sin_family = AF_INET;
    diaChi.sin_addr.s_addr = INADDR_ANY;            // nhan ket noi tu moi dia chi IP cua may
    diaChi.sin_port = htons(cong);
    if (bind(server, reinterpret_cast<sockaddr*>(&diaChi), sizeof(diaChi)) < 0)
    {
        perror("bind");
        return 1;
    }
    listen(server, 8);
    inLog("Server dang nghe o cong " + std::to_string(cong));

    while (true)
    {
        sockaddr_in khach{};
        socklen_t len = sizeof(khach);
        int s = accept(server, reinterpret_cast<sockaddr*>(&khach), &len);
        if (s < 0)
            continue;
        inLog(std::string("Ket noi moi tu ") + inet_ntoa(khach.sin_addr) + ":" + std::to_string(ntohs(khach.sin_port)));
        std::thread(phucVuClient, s).detach();      // moi client mot thread rieng
    }
}

Biên dịch và chạy thử

Biên dịch 2 chương trình, thêm -pthread vì có dùng std::thread:

Biên dịch chương trình server và client chat C++ bằng g++ với tùy chọn -pthread

Mở 3 terminal: một chạy server, hai chạy client. Dưới đây là kết quả thật của một lần chạy, An và Bình chào nhau rồi lần lượt thoát, cuối cùng tắt server bằng Ctrl+C:

Log của server chat: nhận kết nối từ 2 client, chuyển tin nhắn của An và Bình, báo rời phòng

Server in ra mỗi kết nối mới kèm IP và cổng của client. Cổng phía client (52736, 52740) do hệ điều hành tự chọn nên mỗi lần chạy mỗi khác.

Terminal client của An: nhập tên, nhận tin của Bình, gõ /quit để rời phòng

Terminal client của Bình: nhận tin nhắn của An qua server và gõ /quit

Ở mỗi client, dòng có [Ten] hoặc *** là tin nhận từ server, dòng không có là tin do chính người đó gõ. An vào trước nên thấy thông báo Bình vào phòng, còn Bình thì không thấy thông báo An vào.

Để chat giữa 2 máy trong cùng mạng LAN, chạy client với IP của máy server, ví dụ ./client 192.168.1.10 5000, và mở cổng 5000 trên tường lửa của máy server.

Chạy trên Windows với Winsock

Trên Windows, API socket là Winsock, gần giống POSIX nhưng có vài điểm khác:

  • Include <winsock2.h> và <ws2tcpip.h> thay cho các header <sys/socket.h>, <arpa/inet.h>, <unistd.h>.
  • Gọi WSAStartup() ở đầu chương trình và WSACleanup() ở cuối.
  • Dùng closesocket() thay cho close(), kiểu socket là SOCKET thay cho int.
  • Không có cờ MSG_NOSIGNAL, truyền 0 (Windows không gửi tín hiệu SIGPIPE).
  • Biên dịch thêm -lws2_32.

Cách dễ nhất là chạy nguyên code trong bài trên WSL. Nếu muốn một code chạy được mọi nơi, có thể dùng thư viện Asio (cũng có trong Boost), hoặc module sfml-network của SFML ở bài game rắn săn mồi.

Hướng mở rộng

  • Danh sách người online: lệnh /who trả về tên những người đang trong phòng.
  • Tin nhắn riêng: /msg Binh noi dung chỉ gửi cho Bình. Đổi dsClient thành std::map<std::string, int> từ tên sang socket.
  • Không cho trùng tên: kiểm tra tên đã có trong phòng chưa trước khi cho vào.
  • Lưu lịch sử: ghi mọi tin vào file kèm thời gian, người mới vào phòng được gửi 10 tin gần nhất.
  • Server nhiều người dùng hơn: một thread cho mỗi client đơn giản nhưng tốn tài nguyên khi có hàng nghìn kết nối. Server thực tế dùng poll(), epoll() hoặc thư viện Asio để một vài thread phục vụ được rất nhiều client.

Kết

Ứng dụng chat client server C++ cho thấy cách hai chương trình nói chuyện qua mạng: server bind, listen, accept, client connect, sau đó cả hai send và recv. Hai bài học quan trọng nhất là TCP chỉ là một dòng byte, phải tự chia tin nhắn bằng ký tự \n, và mọi dữ liệu dùng chung giữa các thread như danh sách client hay std::cout đều phải được bảo vệ bằng mutex.

Tài liệu tham khảo kinh điển về socket là Beej’s Guide to Network Programming. Bài tiếp theo là bài cuối của serie: áp dụng C++ và lập trình hướng đối tượng cho vi điều khiển, cầu nối sang các serie ESP32, STM32 trên blog. Nếu thấy bài viết có ích, hãy chia sẻ cho bạn bè nhé.

Rate this post

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *